Compare commits
1 Commits
69f87c8b6b
...
feat/local
| Author | SHA1 | Date | |
|---|---|---|---|
| 43a466f7e8 |
26
README.md
26
README.md
@@ -5,7 +5,7 @@ Real-time group voting for the bachelor party — hotels, golf, nightlife, excur
|
|||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd cabo-voting-app
|
cd voting_app
|
||||||
npm install
|
npm install
|
||||||
node server.js
|
node server.js
|
||||||
# → http://localhost:3001
|
# → http://localhost:3001
|
||||||
@@ -14,13 +14,7 @@ node server.js
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Real-time WebSocket voting** — all clients update instantly
|
- **Real-time WebSocket voting** — all clients update instantly
|
||||||
- **6 planning categories** — Hotels, Flights, Golf, Nightlife, Excursions, and Full Itineraries
|
- **5 categories** — Hotels, Golf, Nightlife, Excursions, Full Itineraries
|
||||||
- **Budget planner tab** — compares 8, 10, and 12 guys across Budget, Balanced, and Splurge tracks
|
|
||||||
- **Price trend graphs** — each option shows a live line graph from price-watch automation runs
|
|
||||||
- **Source-selectable price tracking** — switch each option between Apple, Costco, KAYAK, and other tracked sources
|
|
||||||
- **Package vs standalone labels** — bundled flight+hotel quotes stay distinct from room-only, flight-only, tee-time, table, charter, and excursion prices
|
|
||||||
- **Decision detail cards** — automation-enriched pricing, features, amenities, and tradeoffs appear on each option
|
|
||||||
- **Guest authentication** — bachelor-party voters sign in with their name and the last 4 digits of their phone number
|
|
||||||
- **Add suggestions** — anyone can propose new venues
|
- **Add suggestions** — anyone can propose new venues
|
||||||
- **Admin approval** — pending options require approval before going live
|
- **Admin approval** — pending options require approval before going live
|
||||||
- **Responsive** — works on desktop and mobile
|
- **Responsive** — works on desktop and mobile
|
||||||
@@ -28,24 +22,10 @@ node server.js
|
|||||||
## Data
|
## Data
|
||||||
|
|
||||||
Votes are stored in `data/votes.json` (created on first run). Edit directly or use the admin panel.
|
Votes are stored in `data/votes.json` (created on first run). Edit directly or use the admin panel.
|
||||||
System seed data auto-refreshes researched options while preserving existing votes and user-added options.
|
|
||||||
Price-watch automation runs append time-series snapshots in `price-watch/history.jsonl`, which the app turns into per-option trend lines and decision detail cards. Automation output should cover hotels, flights, golf, nightlife, and excursions, with `bookingType` and `priceBasis` separating package quotes from standalone booking prices.
|
|
||||||
When a run includes calculated `budgetScenarios` or `derivedItineraries`, the app uses those fresh automation calculations instead of the static seed budget scenarios.
|
|
||||||
Guest access is rostered in `seed-data.js` and `data/votes.json`; Jon is marked as groom and Toph as best man.
|
|
||||||
The live automation itself runs from `~/.codex/automations/cabo-price-watch/automation.toml`, and its human-readable and machine-readable outputs are written back into this repo under `price-watch/latest-report.md` and `price-watch/history.jsonl`.
|
|
||||||
|
|
||||||
For hosted deployments, set `DATA_DIR` or `DATA_FILE` so mutable vote data lives outside the Git checkout.
|
|
||||||
When price-watch automation updates tracked data files in the repository, commit/push those changes and refresh the Ubuntu deployment so the hosted app picks up the latest option details, price history, itinerary calculations, and budget scenarios.
|
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
The app can run directly under `systemd` with:
|
Deployed on `ice:3001` via Node.js directly (not Docker). Routed through Traefik on `ubuntu` via `cabo-voting.yml`.
|
||||||
|
|
||||||
```bash
|
|
||||||
PORT=3021 DATA_DIR=/srv/state/cabo-voting node server.js
|
|
||||||
```
|
|
||||||
|
|
||||||
Traefik can then reverse proxy to the chosen host port.
|
|
||||||
|
|
||||||
See [Gitea Issues](https://gitea.tophermayor.com/TopherMayor/cabo-voting-app/issues) for the UI/UX roadmap.
|
See [Gitea Issues](https://gitea.tophermayor.com/TopherMayor/cabo-voting-app/issues) for the UI/UX roadmap.
|
||||||
|
|
||||||
|
|||||||
@@ -1,256 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
||||||
import Header from './components/Header'
|
|
||||||
import NameModal from './components/NameModal'
|
|
||||||
import TabBar from './components/TabBar'
|
|
||||||
import OptionList from './components/OptionList'
|
|
||||||
import ResultsTab from './components/ResultsTab'
|
|
||||||
import AddOption from './components/AddOption'
|
|
||||||
import Toast from './components/Toast'
|
|
||||||
import WsOverlay from './components/WsOverlay'
|
|
||||||
import OptionModal from './components/OptionModal'
|
|
||||||
import YourVotesModal from './components/YourVotesModal'
|
|
||||||
import SocialToast from './components/SocialToast'
|
|
||||||
import MapTab from './components/MapTab'
|
|
||||||
import BudgetTab from './components/BudgetTab'
|
|
||||||
import { useWebSocket } from './hooks/useWebSocket'
|
|
||||||
import { useVoterSession } from './hooks/useVoterSession'
|
|
||||||
import { useSound } from './hooks/useSound'
|
|
||||||
import './App.css'
|
|
||||||
|
|
||||||
const SOUND_KEY = 'cabo-voting-sound'
|
|
||||||
const THEME_KEY = 'cabo-voting-theme'
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
const [categories, setCategories] = useState([])
|
|
||||||
const [options, setOptions] = useState([])
|
|
||||||
const [pollsOpen, setPollsOpen] = useState(true)
|
|
||||||
const [totalVoters, setTotalVoters] = useState(0)
|
|
||||||
const [activeTab, setActiveTab] = useState('hotel')
|
|
||||||
const [toast, setToast] = useState(null)
|
|
||||||
const [selectedOption, setSelectedOption] = useState(null) // detail modal
|
|
||||||
const [yourVotesOpen, setYourVotesOpen] = useState(false) // your votes modal
|
|
||||||
const [socialToast, setSocialToast] = useState(null) // floating vote toast
|
|
||||||
const [soundEnabled, setSoundEnabled] = useState(() => localStorage.getItem(SOUND_KEY) !== 'off')
|
|
||||||
const [theme, setTheme] = useState(() => localStorage.getItem(THEME_KEY) || 'dark')
|
|
||||||
const [onlineCount, setOnlineCount] = useState(0)
|
|
||||||
const [pollDeadline, setPollDeadline] = useState(null)
|
|
||||||
|
|
||||||
const socialToastTimer = useRef(null)
|
|
||||||
const { voterName, setVoterName, clearVoter } = useVoterSession()
|
|
||||||
const { playVoteSound, playRemoveSound } = useSound()
|
|
||||||
|
|
||||||
const pollsExpired = pollDeadline && Date.now() > new Date(pollDeadline).getTime()
|
|
||||||
|
|
||||||
const { wsRef, wsConnected, reconnect } = useWebSocket({
|
|
||||||
setCategories, setOptions, setPollsOpen, setTotalVoters,
|
|
||||||
setOnlineCount, setPollDeadline,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Apply theme to body
|
|
||||||
useEffect(() => {
|
|
||||||
document.body.dataset.theme = theme
|
|
||||||
localStorage.setItem(THEME_KEY, theme)
|
|
||||||
}, [theme])
|
|
||||||
|
|
||||||
const showToast = useCallback((msg, type = '') => {
|
|
||||||
setToast({ msg, type })
|
|
||||||
setTimeout(() => setToast(null), 3000)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleVote = useCallback((option, removed = false) => {
|
|
||||||
if (!voterName) return
|
|
||||||
if (!pollsOpen || pollsExpired) return
|
|
||||||
const opt = options.find(o => o.id === option.id)
|
|
||||||
if (!opt) return
|
|
||||||
|
|
||||||
const alreadyVoted = opt.votes?.some(v => v.name === voterName)
|
|
||||||
|
|
||||||
// Optimistic update
|
|
||||||
setOptions(prev => prev.map(o => {
|
|
||||||
if (o.id !== option.id) return o
|
|
||||||
if (removed || alreadyVoted) {
|
|
||||||
return { ...o, votes: o.votes.filter(v => v.name !== voterName) }
|
|
||||||
} else {
|
|
||||||
return { ...o, votes: [...(o.votes || []), { name: voterName, timestamp: Date.now() }] }
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
const ws = wsRef.current
|
|
||||||
if (ws?.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send(JSON.stringify({ type: 'vote', optionId: option.id, voterName, remove: removed || alreadyVoted }))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (removed || alreadyVoted) {
|
|
||||||
playRemoveSound()
|
|
||||||
showToast(`Removed vote for ${opt.name}`)
|
|
||||||
} else {
|
|
||||||
playVoteSound()
|
|
||||||
showToast(`Voted for ${opt.name}!`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Social toast for others (shows voter name + option name)
|
|
||||||
setSocialToast({ voterName, optionName: opt.name, categoryId: opt.categoryId })
|
|
||||||
clearTimeout(socialToastTimer.current)
|
|
||||||
socialToastTimer.current = setTimeout(() => setSocialToast(null), 3000)
|
|
||||||
}, [voterName, options, pollsOpen, pollsExpired, wsRef, playVoteSound, playRemoveSound, showToast])
|
|
||||||
|
|
||||||
const handleRemoveVote = useCallback((optionId) => {
|
|
||||||
const opt = options.find(o => o.id === optionId)
|
|
||||||
if (opt) handleVote(opt, true)
|
|
||||||
}, [options, handleVote])
|
|
||||||
|
|
||||||
// Check URL params on load
|
|
||||||
useEffect(() => {
|
|
||||||
const params = new URLSearchParams(location.search)
|
|
||||||
if (params.get('view') === 'results') setActiveTab('results')
|
|
||||||
const optionId = params.get('option')
|
|
||||||
if (optionId) {
|
|
||||||
// Wait for options to load, then open modal
|
|
||||||
const check = () => {
|
|
||||||
const opt = options.find(o => o.id === optionId)
|
|
||||||
if (opt) { setSelectedOption(opt); } else if (options.length > 0) { setSelectedOption(null) }
|
|
||||||
}
|
|
||||||
check()
|
|
||||||
}
|
|
||||||
}, []) // eslint-disable-line
|
|
||||||
|
|
||||||
const handleAddSubmit = useCallback((data) => {
|
|
||||||
if (!voterName) { showToast('Enter your name first', 'error'); return }
|
|
||||||
const ws = wsRef.current
|
|
||||||
if (ws?.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send(JSON.stringify({ type: 'add_option', ...data, voterName }))
|
|
||||||
}
|
|
||||||
document.getElementById('add-name').value = ''
|
|
||||||
document.getElementById('add-desc').value = ''
|
|
||||||
document.getElementById('add-url').value = ''
|
|
||||||
showToast(`Submitted "${data.name}" for approval!`, 'success')
|
|
||||||
}, [voterName, wsRef, showToast])
|
|
||||||
|
|
||||||
const toggleSound = useCallback(() => {
|
|
||||||
setSoundEnabled(prev => {
|
|
||||||
const next = !prev
|
|
||||||
localStorage.setItem(SOUND_KEY, next ? 'on' : 'off')
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const toggleTheme = useCallback(() => {
|
|
||||||
setTheme(prev => prev === 'dark' ? 'light' : 'dark')
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const votingCats = categories.filter(c => c.id !== 'results')
|
|
||||||
const optionCounts = votingCats.reduce((acc, cat) => {
|
|
||||||
acc[cat.id] = options.filter(o => o.categoryId === cat.id).length
|
|
||||||
return acc
|
|
||||||
}, {})
|
|
||||||
|
|
||||||
// "Your Votes" — all options this voter voted for
|
|
||||||
const yourVotes = options.filter(o => o.votes?.some(v => v.name === voterName))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<a className="skip-link" href="#main">Skip to content</a>
|
|
||||||
|
|
||||||
<Header
|
|
||||||
voterName={voterName}
|
|
||||||
pollsOpen={pollsOpen}
|
|
||||||
totalVoters={totalVoters}
|
|
||||||
wsConnected={wsConnected}
|
|
||||||
onChangeName={clearVoter}
|
|
||||||
soundEnabled={soundEnabled}
|
|
||||||
onToggleSound={toggleSound}
|
|
||||||
theme={theme}
|
|
||||||
onToggleTheme={toggleTheme}
|
|
||||||
onlineCount={onlineCount}
|
|
||||||
pollDeadline={pollDeadline}
|
|
||||||
pollsExpired={pollsExpired}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!voterName && <NameModal onSubmit={setVoterName} />}
|
|
||||||
|
|
||||||
<TabBar
|
|
||||||
categories={categories}
|
|
||||||
activeTab={activeTab}
|
|
||||||
onTab={setActiveTab}
|
|
||||||
optionCounts={optionCounts}
|
|
||||||
onYourVotes={() => setYourVotesOpen(true)}
|
|
||||||
voterName={voterName}
|
|
||||||
yourVotesCount={yourVotes.length}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<main id="main">
|
|
||||||
{activeTab === 'results' ? (
|
|
||||||
<ResultsTab
|
|
||||||
categories={categories}
|
|
||||||
options={options}
|
|
||||||
pollsOpen={pollsOpen}
|
|
||||||
totalVoters={totalVoters}
|
|
||||||
/>
|
|
||||||
) : activeTab === 'map' ? (
|
|
||||||
<MapTab
|
|
||||||
options={options}
|
|
||||||
categories={categories}
|
|
||||||
onSelectOption={setSelectedOption}
|
|
||||||
/>
|
|
||||||
) : activeTab === 'budget' ? (
|
|
||||||
<BudgetTab
|
|
||||||
options={options}
|
|
||||||
categories={categories}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<OptionList
|
|
||||||
options={options.filter(o => o.categoryId === activeTab && o.approved)}
|
|
||||||
voterName={voterName}
|
|
||||||
pollsOpen={pollsOpen}
|
|
||||||
pollsExpired={pollsExpired}
|
|
||||||
onVote={handleVote}
|
|
||||||
onCardClick={setSelectedOption}
|
|
||||||
categoryId={activeTab}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab !== 'results' && activeTab !== 'map' && activeTab !== 'budget' && (
|
|
||||||
<AddOption
|
|
||||||
categories={categories}
|
|
||||||
voterName={voterName}
|
|
||||||
onSubmit={handleAddSubmit}
|
|
||||||
onNeedsName={() => showToast('Enter your name first', 'error')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Option detail modal */}
|
|
||||||
{selectedOption && (
|
|
||||||
<OptionModal
|
|
||||||
option={selectedOption}
|
|
||||||
voterName={voterName}
|
|
||||||
onClose={() => setSelectedOption(null)}
|
|
||||||
onVote={handleVote}
|
|
||||||
categories={categories}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Your votes modal */}
|
|
||||||
{yourVotesOpen && (
|
|
||||||
<YourVotesModal
|
|
||||||
votes={yourVotes}
|
|
||||||
voterName={voterName}
|
|
||||||
onClose={() => setYourVotesOpen(false)}
|
|
||||||
onRemoveVote={handleRemoveVote}
|
|
||||||
onViewOption={setSelectedOption}
|
|
||||||
categories={categories}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<WsOverlay connected={wsConnected} onReconnect={reconnect} />
|
|
||||||
{toast && <Toast msg={toast.msg} type={toast.type} />}
|
|
||||||
{socialToast && (
|
|
||||||
<SocialToast
|
|
||||||
voterName={socialToast.voterName}
|
|
||||||
optionName={socialToast.optionName}
|
|
||||||
categoryId={socialToast.categoryId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
.budget-tab {
|
|
||||||
padding: 1rem;
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.budget-header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.budget-header h2 {
|
|
||||||
margin: 0 0 0.25rem;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.budget-header p {
|
|
||||||
margin: 0;
|
|
||||||
opacity: 0.7;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.budget-leaders {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.budget-leaders h3 {
|
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaders-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leader-card {
|
|
||||||
background: var(--card-bg, #1e1e2e);
|
|
||||||
border: 1px solid var(--border, #2a2a3a);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 0.875rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leader-cat {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
opacity: 0.6;
|
|
||||||
margin-bottom: 0.375rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leader-name {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leader-name.no-votes {
|
|
||||||
opacity: 0.4;
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leader-tier {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leader-price {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
color: #00d4ff;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leader-votes {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tier pills */
|
|
||||||
.budget-tiers {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tier-card {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
padding: 0.6rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 2px solid var(--border, #2a2a3a);
|
|
||||||
opacity: 0.5;
|
|
||||||
transition: all 0.2s;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tier-card.active {
|
|
||||||
opacity: 1;
|
|
||||||
border-color: var(--tier-color);
|
|
||||||
background: color-mix(in srgb, var(--tier-color) 10%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tier-label {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tier-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: -8px;
|
|
||||||
right: -8px;
|
|
||||||
background: var(--tier-color);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 0.6rem;
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 10px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Breakdown table */
|
|
||||||
.budget-breakdown h3 {
|
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breakdown-table {
|
|
||||||
background: var(--card-bg, #1e1e2e);
|
|
||||||
border: 1px solid var(--border, #2a2a3a);
|
|
||||||
border-radius: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breakdown-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1.4fr repeat(3, 70px);
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
border-bottom: 1px solid var(--border, #2a2a3a);
|
|
||||||
}
|
|
||||||
|
|
||||||
.source-cell {
|
|
||||||
font-size: 0.65rem;
|
|
||||||
opacity: 0.6;
|
|
||||||
text-align: left !important;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breakdown-row:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breakdown-row.header-row {
|
|
||||||
font-weight: 700;
|
|
||||||
opacity: 0.6;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
background: var(--bg-secondary, #16161e);
|
|
||||||
}
|
|
||||||
|
|
||||||
.breakdown-row span {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breakdown-row span:first-child {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breakdown-row.total-row {
|
|
||||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breakdown-row.group-row {
|
|
||||||
background: var(--bg-secondary, #16161e);
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.budget-note {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
opacity: 0.5;
|
|
||||||
margin: 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Placeholder */
|
|
||||||
.budget-placeholder {
|
|
||||||
text-align: center;
|
|
||||||
padding: 3rem 1rem;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-icon {
|
|
||||||
font-size: 3rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.budget-placeholder p {
|
|
||||||
max-width: 300px;
|
|
||||||
margin: 0 auto;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
@@ -1,371 +0,0 @@
|
|||||||
import { useMemo } from 'react'
|
|
||||||
import './BudgetTab.css'
|
|
||||||
|
|
||||||
// ─── Price parsers ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function extractPrices(details) {
|
|
||||||
if (!details || !details.length) return {}
|
|
||||||
const text = details.join(' ')
|
|
||||||
const prices = {}
|
|
||||||
|
|
||||||
// Per-person package: "Apple exact-date quote: $2,016 pp" / "Costco package: $1,678.99 pp"
|
|
||||||
const ppMatch = text.match(/\$([\d,]+(?:\.\d{2})?)\s*(?:pp|per person|per-person)/i)
|
|
||||||
if (ppMatch) prices.perPerson = parseFloat(ppMatch[1].replace(/,/g, ''))
|
|
||||||
|
|
||||||
// Per-night: "KAYAK from $212/night" / "KAYAK exact-date room rate: $335/night"
|
|
||||||
const pnMatch = text.match(/\$([\d,]+(?:\.\d{2})?)\s*(?:\/night|per night|per-night)/i)
|
|
||||||
if (pnMatch) prices.perNight = parseFloat(pnMatch[1].replace(/,/g, ''))
|
|
||||||
|
|
||||||
// Group-total with size: "Pool island for 4: $884" / "Gold package for 16 guests: $1,700"
|
|
||||||
// Group-total with size: "Pool island for 4: $884" / "Gold package for 16 guests: $1,700"
|
|
||||||
const groupMatch = text.match(/(?:for|at)\s+(\d+)[^\d$]*\$([\d,]+(?:\.\d{2})?)/i)
|
|
||||||
|| text.match(/\$([\d,]+(?:\.\d{2})?)\s+(?:total|for\s+\d+)/i)
|
|
||||||
if (groupMatch) {
|
|
||||||
prices.groupTotal = parseFloat((groupMatch[2] || groupMatch[1]).replace(/,/g, ''))
|
|
||||||
const sizeMatch = text.match(/for\s+(\d+)\s*[^\d$]*/i)
|
|
||||||
if (sizeMatch) prices.groupSize = parseInt(sizeMatch[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple "From $1,504 total"
|
|
||||||
if (!prices.groupTotal) {
|
|
||||||
const totalMatch = text.match(/(?:from\s+)?\$([\d,]+(?:\.\d{2})?)\s+total/i)
|
|
||||||
if (totalMatch) prices.groupTotal = parseFloat(totalMatch[1].replace(/,/g, ''))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-person from group total: "About $188 pp at 8"
|
|
||||||
const ppAtMatch = text.match(/\$\d+[\d,.]*\s*(?:pp|per person).*?at\s+(\d+)/i)
|
|
||||||
|| text.match(/about?\$([\d,]+(?:\.\d{2})?)\s*(?:pp|per person).*?at\s+(\d+)/i)
|
|
||||||
if (ppAtMatch) {
|
|
||||||
prices.perPerson = parseFloat(ppAtMatch[1].replace(/,/g, ''))
|
|
||||||
prices.atGroupSize = parseInt(ppAtMatch[2])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Planning number: "Use about $180 as current planning number"
|
|
||||||
const planMatch = text.match(/use\s+(?:about\s+)?\$([\d,]+(?:\.\d{2})?)\s+(?:as\s+)?(?:the\s+)?(?:current\s+)?planning/i)
|
|
||||||
if (planMatch && !prices.perPerson) prices.planningRate = parseFloat(planMatch[1].replace(/,/g, ''))
|
|
||||||
|
|
||||||
// Flat per-person: "From $76" — only if no other signals
|
|
||||||
if (!prices.perPerson && !prices.perNight && !prices.groupTotal && !prices.planningRate) {
|
|
||||||
const flatMatch = text.match(/(?:from\s+)?\$([\d,]+(?:\.\d{2})?)\b(?!.*\b(?:pp|per|total|for\b)/i)
|
|
||||||
if (flatMatch) {
|
|
||||||
const val = parseFloat(flatMatch[1].replace(/,/g, ''))
|
|
||||||
if (val > 20) prices.perPerson = val
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return prices
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Check whether an option has real scraped pricing ─────────────────────────
|
|
||||||
|
|
||||||
function hasRealPricing(option) {
|
|
||||||
const p = extractPrices(option.details || [])
|
|
||||||
// Has at least one real price signal (not just a planning estimate or generic text)
|
|
||||||
return !!(p.perPerson || p.perNight || p.groupTotal || p.planningRate)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Resolve per-person cost for an option ───────────────────────────────────
|
|
||||||
|
|
||||||
function resolvePerPerson(option, fallback) {
|
|
||||||
const p = extractPrices(option.details || [])
|
|
||||||
if (p.perPerson) return p.perPerson
|
|
||||||
if (p.perNight) return p.perNight * 3 // assume 3-night stay
|
|
||||||
if (p.groupTotal && p.groupSize) return Math.round(p.groupTotal / p.groupSize)
|
|
||||||
if (p.groupTotal && p.atGroupSize) {
|
|
||||||
// scale to 8-person group as baseline
|
|
||||||
return Math.round(p.groupTotal / p.atGroupSize)
|
|
||||||
}
|
|
||||||
if (p.groupTotal) return Math.round(p.groupTotal / 8)
|
|
||||||
if (p.planningRate) return p.planningRate
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Fixed costs ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const FLIGHT_PP = 350
|
|
||||||
const FOOD_BUFFER = 275
|
|
||||||
|
|
||||||
// ─── Per-person costs from real scraped data (used when details are empty) ──
|
|
||||||
|
|
||||||
const FALLBACK_HOTEL = { budget: 450, balanced: 850, splurge: 1250 }
|
|
||||||
const FALLBACK_GOLF = { budget: 130, balanced: 180, splurge: 250 }
|
|
||||||
const FALLBACK_NIGHTLIFE = { budget: 40, balanced: 100, splurge: 200 }
|
|
||||||
const FALLBACK_EXCURSION = { budget: 76, balanced: 110, splurge: 188 }
|
|
||||||
const FALLBACK_TRANSFER = { budget: 33, balanced: 83, splurge: 183 }
|
|
||||||
|
|
||||||
// ─── Tier classification by hotel price signal ──────────────────────────────
|
|
||||||
|
|
||||||
function classifyTier(optionId, perPerson) {
|
|
||||||
// Use the actual scraped per-person package prices as ground truth
|
|
||||||
const KNOWN_PP = {
|
|
||||||
// Hotels — package prices
|
|
||||||
'hotel-corazon': null, // no live rate
|
|
||||||
'hotel-breathless': 1678.99, // Costco package
|
|
||||||
'hotel-grand-fiesta': null, // no Costco live, Apple $2,111
|
|
||||||
'hotel-secrets': 2005.80, // Costco package
|
|
||||||
'hotel-pacifica': null, // no live rate
|
|
||||||
'hotel-dreams': null,
|
|
||||||
// Hotels — nightly rates → scaled to 3-night
|
|
||||||
// KAYAK per-night rates × 3 nights
|
|
||||||
}
|
|
||||||
if (KNOWN_PP[optionId] !== undefined && KNOWN_PP[optionId] !== null) {
|
|
||||||
const pp = KNOWN_PP[optionId]
|
|
||||||
if (pp <= 1000) return 'budget'
|
|
||||||
if (pp <= 1900) return 'balanced'
|
|
||||||
return 'splurge'
|
|
||||||
}
|
|
||||||
// Nightly rate proxies (×3 nights)
|
|
||||||
if (perPerson <= 300) return 'budget'
|
|
||||||
if (perPerson <= 900) return 'balanced'
|
|
||||||
return 'splurge'
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Category label / emoji ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const CAT_LABELS = {
|
|
||||||
hotel: '🏨 Hotel',
|
|
||||||
golf: '⛳ Golf',
|
|
||||||
nightlife: '🎧 Nightlife',
|
|
||||||
excursion: '🚤 Excursion',
|
|
||||||
}
|
|
||||||
|
|
||||||
const GROUP_SIZES = [8, 10, 12]
|
|
||||||
|
|
||||||
// ─── Main component ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function getWinningOption(options, categoryId) {
|
|
||||||
const catOpts = options.filter(o => o.categoryId === categoryId && o.approved)
|
|
||||||
if (!catOpts.length) return null
|
|
||||||
return catOpts.reduce((best, o) =>
|
|
||||||
(o.votes?.length || 0) > (best.votes?.length || 0) ? o : best
|
|
||||||
, catOpts[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
function dominantTier(tiers) {
|
|
||||||
const counts = { budget: 0, balanced: 0, splurge: 0 }
|
|
||||||
Object.values(tiers).forEach(t => { if (t) counts[t] = (counts[t] || 0) + 1 })
|
|
||||||
if (counts.splurge >= 2) return 'splurge'
|
|
||||||
if (counts.budget >= 2) return 'budget'
|
|
||||||
return Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0] || 'balanced'
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPrice(v) {
|
|
||||||
if (!v) return '—'
|
|
||||||
return '$' + v.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPriceLabel(option) {
|
|
||||||
const p = extractPrices(option.details || [])
|
|
||||||
const text = (option.details || []).join(' ')
|
|
||||||
|
|
||||||
if (p.perPerson) {
|
|
||||||
// Prefer package price label
|
|
||||||
const pkgMatch = text.match(/(Costco|Apple).*?\$[\d,]+(?:\.\d{2})?\s*(?:pp|per person)/i)
|
|
||||||
if (pkgMatch) {
|
|
||||||
const m = text.match(/\$([\d,]+(?:\.\d{2})?)\s*(?:pp|per person)/i)
|
|
||||||
if (m) return `pkg ${formatPrice(parseFloat(m[1].replace(/,/g, '')))}/pp`
|
|
||||||
}
|
|
||||||
return formatPrice(p.perPerson) + '/pp'
|
|
||||||
}
|
|
||||||
if (p.perNight) {
|
|
||||||
const nights = 3
|
|
||||||
return formatPrice(p.perNight) + '/night → ' + formatPrice(p.perNight * nights) + ' for 3 nights'
|
|
||||||
}
|
|
||||||
if (p.groupTotal && p.groupSize) {
|
|
||||||
const pp = Math.round(p.groupTotal / p.groupSize)
|
|
||||||
return formatPrice(p.groupTotal) + ' total ÷ ' + p.groupSize + ' = ' + formatPrice(pp) + '/pp'
|
|
||||||
}
|
|
||||||
if (p.groupTotal) {
|
|
||||||
const pp = Math.round(p.groupTotal / 8)
|
|
||||||
return formatPrice(p.groupTotal) + ' group total (~' + formatPrice(pp) + '/pp)'
|
|
||||||
}
|
|
||||||
if (p.planningRate) return '~$' + Math.round(p.planningRate) + '/pp planning'
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BudgetTab({ options, categories }) {
|
|
||||||
const votingCategories = ['hotel', 'golf', 'nightlife', 'excursion']
|
|
||||||
|
|
||||||
const leaders = useMemo(() => {
|
|
||||||
const result = {}
|
|
||||||
votingCategories.forEach(cat => {
|
|
||||||
result[cat] = getWinningOption(options, cat)
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
}, [options])
|
|
||||||
|
|
||||||
// Only include leaders that have actual scraped pricing
|
|
||||||
const pricedLeaders = useMemo(() => {
|
|
||||||
const result = {}
|
|
||||||
votingCategories.forEach(cat => {
|
|
||||||
const leader = leaders[cat]
|
|
||||||
if (leader && hasRealPricing(leader)) result[cat] = leader
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
}, [leaders])
|
|
||||||
|
|
||||||
// Determine tier per category based on actual prices
|
|
||||||
const tiers = useMemo(() => {
|
|
||||||
const t = {}
|
|
||||||
votingCategories.forEach(cat => {
|
|
||||||
const leader = pricedLeaders[cat]
|
|
||||||
if (!leader) { t[cat] = 'balanced'; return }
|
|
||||||
const pp = resolvePerPerson(leader, null)
|
|
||||||
t[cat] = classifyTier(leader.id, pp)
|
|
||||||
})
|
|
||||||
return t
|
|
||||||
}, [pricedLeaders])
|
|
||||||
|
|
||||||
const overallTier = useMemo(() => dominantTier(tiers), [tiers])
|
|
||||||
|
|
||||||
// Build the cost breakdown from real prices only
|
|
||||||
const costRows = useMemo(() => {
|
|
||||||
const rows = {}
|
|
||||||
|
|
||||||
votingCategories.forEach(cat => {
|
|
||||||
const leader = pricedLeaders[cat]
|
|
||||||
if (!leader) return // skip categories without priced leaders
|
|
||||||
const pp = resolvePerPerson(leader, null)
|
|
||||||
rows[cat] = { label: CAT_LABELS[cat], pp, source: leader.name }
|
|
||||||
})
|
|
||||||
|
|
||||||
// Transfer depends on tier (only show if we have at least a hotel)
|
|
||||||
if (Object.keys(pricedLeaders).length > 0) {
|
|
||||||
rows.transfer = { label: '🚗 Transfers', pp: FALLBACK_TRANSFER[overallTier], source: 'shared shuttle estimate' }
|
|
||||||
}
|
|
||||||
rows.flight = { label: '✈️ Flights', pp: FLIGHT_PP, source: 'group average estimate' }
|
|
||||||
rows.food = { label: '🍽️ Food + drinks', pp: FOOD_BUFFER, source: 'buffer for 3 days' }
|
|
||||||
|
|
||||||
return rows
|
|
||||||
}, [pricedLeaders, overallTier])
|
|
||||||
|
|
||||||
const totals = useMemo(() => {
|
|
||||||
return GROUP_SIZES.map(size => {
|
|
||||||
const sum = Object.values(costRows).reduce((acc, row) => acc + (row.pp || 0), 0)
|
|
||||||
return { size, perPerson: Math.round(sum), total: Math.round(sum * size) }
|
|
||||||
})
|
|
||||||
}, [costRows])
|
|
||||||
|
|
||||||
const hasPricedLeaders = Object.keys(pricedLeaders).length > 0
|
|
||||||
|
|
||||||
const tierColor = { budget: '#22c55e', balanced: '#3b82f6', splurge: '#f97316' }
|
|
||||||
const tierLabel = { budget: '💰 Budget', balanced: '⚖️ Balanced', splurge: '💎 Splurge' }
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="budget-tab">
|
|
||||||
<div className="budget-header">
|
|
||||||
<h2>Trip Budget Calculator</h2>
|
|
||||||
<p>Prices sourced from live travel data — updates as votes come in</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Category leaders with real prices */}
|
|
||||||
<div className="budget-leaders">
|
|
||||||
<h3>Current Leaders — Live Prices</h3>
|
|
||||||
{Object.keys(pricedLeaders).length > 0 ? (
|
|
||||||
<div className="leaders-grid">
|
|
||||||
{votingCategories.map(cat => {
|
|
||||||
const leader = pricedLeaders[cat]
|
|
||||||
if (!leader) return null
|
|
||||||
const catTier = tiers[cat]
|
|
||||||
const priceLabel = getPriceLabel(leader)
|
|
||||||
return (
|
|
||||||
<div key={cat} className="leader-card" style={{ '--cat-color': tierColor[catTier] }}>
|
|
||||||
<div className="leader-cat">{CAT_LABELS[cat]}</div>
|
|
||||||
<div className="leader-name">{leader.name}</div>
|
|
||||||
{priceLabel && <div className="leader-price">{priceLabel}</div>}
|
|
||||||
<div className="leader-tier" style={{ color: tierColor[catTier] }}>
|
|
||||||
{tierLabel[catTier]}
|
|
||||||
</div>
|
|
||||||
<div className="leader-votes">
|
|
||||||
{leader.votes?.length > 0
|
|
||||||
? `${leader.votes.length} vote${leader.votes.length !== 1 ? 's' : ''}`
|
|
||||||
: 'no votes yet'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="budget-placeholder">
|
|
||||||
<div className="placeholder-icon">⏳</div>
|
|
||||||
<p>Waiting for pricing data. Options without live prices from travel sites are hidden until the scraper updates them.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tier summary pills */}
|
|
||||||
<div className="budget-tiers">
|
|
||||||
{['budget', 'balanced', 'splurge'].map(tier => (
|
|
||||||
<div
|
|
||||||
key={tier}
|
|
||||||
className={`tier-card ${overallTier === tier ? 'active' : ''}`}
|
|
||||||
style={{ '--tier-color': tierColor[tier] }}
|
|
||||||
>
|
|
||||||
<div className="tier-label">{tierLabel[tier]}</div>
|
|
||||||
{overallTier === tier && <div className="tier-badge">Current</div>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dynamic cost breakdown */}
|
|
||||||
{hasPricedLeaders ? (
|
|
||||||
<div className="budget-breakdown">
|
|
||||||
<h3>Estimated Cost — {tierLabel[overallTier]} Tier</h3>
|
|
||||||
<div className="breakdown-table">
|
|
||||||
<div className="breakdown-row header-row">
|
|
||||||
<span>Line Item</span>
|
|
||||||
<span>Source</span>
|
|
||||||
{GROUP_SIZES.map(s => <span key={s}>{s} guys</span>)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{[
|
|
||||||
costRows.flight,
|
|
||||||
costRows.hotel,
|
|
||||||
costRows.golf,
|
|
||||||
costRows.nightlife,
|
|
||||||
costRows.excursion,
|
|
||||||
costRows.transfer,
|
|
||||||
costRows.food,
|
|
||||||
].map(row => row && (
|
|
||||||
<div className="breakdown-row" key={row.label}>
|
|
||||||
<span>{row.label}</span>
|
|
||||||
<span className="source-cell">{row.source || '—'}</span>
|
|
||||||
{GROUP_SIZES.map(s => (
|
|
||||||
<span key={s}>{formatPrice(row.pp)}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="breakdown-row total-row">
|
|
||||||
<span>Est. Per Person</span>
|
|
||||||
<span className="source-cell">sum</span>
|
|
||||||
{totals.map(t => (
|
|
||||||
<span key={t.size} style={{ color: tierColor[overallTier], fontWeight: 700 }}>
|
|
||||||
{formatPrice(t.perPerson)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="breakdown-row group-row">
|
|
||||||
<span>Est. Group Total</span>
|
|
||||||
<span className="source-cell"></span>
|
|
||||||
{totals.map(t => (
|
|
||||||
<span key={t.size} style={{ color: tierColor[overallTier], fontWeight: 700 }}>
|
|
||||||
{formatPrice(t.total)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="budget-note">
|
|
||||||
* Prices from live scraped data. Hotel rates are package or nightly × 3 nights.
|
|
||||||
Per-person costs shown are the same for all group sizes. Add bar tabs and tips separately.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="budget-placeholder">
|
|
||||||
<div className="placeholder-icon">🗳️</div>
|
|
||||||
<p>No votes cast yet. Vote in the Hotel, Golf, Nightlife, and Excursion tabs and the budget will update automatically using real scraped prices.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
.modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0,0,0,0.8);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 32px;
|
|
||||||
width: 360px;
|
|
||||||
max-width: 90vw;
|
|
||||||
text-align: center;
|
|
||||||
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal h2 { font-size: 1.3rem; margin-bottom: 6px; color: var(--accent); }
|
|
||||||
.modal p { color: var(--text-muted); font-size: 0.85rem; margin-bottom: 20px; line-height: 1.5; }
|
|
||||||
|
|
||||||
.modal form { display: flex; flex-direction: column; gap: 12px; }
|
|
||||||
|
|
||||||
.modal input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px 14px;
|
|
||||||
background: var(--surface2);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 1rem;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
}
|
|
||||||
.modal input:focus { border-color: var(--accent); }
|
|
||||||
|
|
||||||
.modal button {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--bg);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 700;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
|
||||||
.modal button:hover:not(:disabled) { opacity: 0.85; }
|
|
||||||
.modal button:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
||||||
|
|
||||||
.name-error {
|
|
||||||
color: #f87171;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
background: rgba(248, 113, 113, 0.1);
|
|
||||||
border: 1px solid rgba(248, 113, 113, 0.3);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pin-input {
|
|
||||||
letter-spacing: 0.3em !important;
|
|
||||||
font-size: 1.5rem !important;
|
|
||||||
text-align: center !important;
|
|
||||||
font-family: monospace !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-btn {
|
|
||||||
background: transparent !important;
|
|
||||||
color: var(--text-muted) !important;
|
|
||||||
border: 1px solid var(--border) !important;
|
|
||||||
font-weight: 400 !important;
|
|
||||||
font-size: 0.8rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.modal-overlay { align-items: flex-end; justify-content: stretch; padding: 0; }
|
|
||||||
.modal {
|
|
||||||
width: 100%;
|
|
||||||
border-radius: 20px 20px 0 0;
|
|
||||||
padding: 12px 24px 32px;
|
|
||||||
max-height: 85vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
box-shadow: 0 -8px 40px rgba(0,0,0,0.6);
|
|
||||||
animation: slideUp 0.3s ease-out;
|
|
||||||
}
|
|
||||||
@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
|
||||||
.drag-handle {
|
|
||||||
width: 40px; height: 4px;
|
|
||||||
background: var(--border);
|
|
||||||
border-radius: 2px;
|
|
||||||
margin: 0 auto 14px;
|
|
||||||
}
|
|
||||||
.modal h2 { font-size: 1.15rem; }
|
|
||||||
.modal p { font-size: 0.8rem; margin-bottom: 14px; }
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
|
||||||
import { GROOMSMEN, validateGroomsman } from '../groommen'
|
|
||||||
import './NameModal.css'
|
|
||||||
|
|
||||||
const STORAGE_KEY_PIN = 'cabo_voter_pin'
|
|
||||||
|
|
||||||
export default function NameModal({ onSubmit }) {
|
|
||||||
const [name, setName] = useState('')
|
|
||||||
const [pin, setPin] = useState('')
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
const [step, setStep] = useState(1) // 1 = name, 2 = pin
|
|
||||||
const inputRef = useRef(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (step === 1) inputRef.current?.focus()
|
|
||||||
}, [step])
|
|
||||||
|
|
||||||
const handleNameNext = (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!name.trim()) return
|
|
||||||
|
|
||||||
const key = name.trim().toLowerCase().replace(/\s+/g, '')
|
|
||||||
if (!GROOMSMEN[key]) {
|
|
||||||
setError(`"${name}" is not on the guest list. Check with the groom.`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setError('')
|
|
||||||
setStep(2)
|
|
||||||
// Pre-fill pin from localStorage if saved
|
|
||||||
const saved = localStorage.getItem(STORAGE_KEY_PIN)
|
|
||||||
if (saved) setPin(saved)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePinSubmit = (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (pin.length !== 4) {
|
|
||||||
setError('Enter the last 4 digits of your phone number.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = name.trim().toLowerCase().replace(/\s+/g, '')
|
|
||||||
if (!validateGroomsman(name, pin)) {
|
|
||||||
setError('Wrong PIN. Make sure you\'re using the correct name + last 4 digits.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.setItem(STORAGE_KEY_PIN, pin)
|
|
||||||
onSubmit(name.trim())
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBack = () => {
|
|
||||||
setStep(1)
|
|
||||||
setPin('')
|
|
||||||
setError('')
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="modal-overlay">
|
|
||||||
<div className="modal">
|
|
||||||
<div className="drag-handle"></div>
|
|
||||||
|
|
||||||
{step === 1 ? (
|
|
||||||
<>
|
|
||||||
<h2>🏄 Who's Voting?</h2>
|
|
||||||
<p>Enter your <strong>full name</strong> as it appears on the guest list.</p>
|
|
||||||
<form onSubmit={handleNameNext}>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={name}
|
|
||||||
onChange={e => { setName(e.target.value); setError('') }}
|
|
||||||
placeholder="e.g. Jon, Toph, Hans…"
|
|
||||||
maxLength={30}
|
|
||||||
autoComplete="off"
|
|
||||||
autoCapitalize="words"
|
|
||||||
/>
|
|
||||||
{error && <div className="name-error">{error}</div>}
|
|
||||||
<button type="submit" disabled={!name.trim()}>Next →</button>
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<h2>🔐 Verify with PIN</h2>
|
|
||||||
<p>Enter the <strong>last 4 digits</strong> of your phone number, {name.split(' ')[0]}.</p>
|
|
||||||
<form onSubmit={handlePinSubmit}>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="password"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
maxLength={4}
|
|
||||||
value={pin}
|
|
||||||
onChange={e => { setPin(e.target.value.replace(/\D/g, '')); setError('') }}
|
|
||||||
placeholder="••••"
|
|
||||||
autoComplete="off"
|
|
||||||
className="pin-input"
|
|
||||||
/>
|
|
||||||
{error && <div className="name-error">{error}</div>}
|
|
||||||
<button type="submit" disabled={pin.length !== 4}>Join the Vote →</button>
|
|
||||||
<button type="button" className="back-btn" onClick={handleBack}>← Back</button>
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
// Groomsmen: name (lowercase) → last 4 digits of phone number
|
|
||||||
export const GROOMSMEN = {
|
|
||||||
jon: '7506',
|
|
||||||
toph: '8116',
|
|
||||||
hans: '6681',
|
|
||||||
janno: '2809',
|
|
||||||
jt: '3286',
|
|
||||||
Cordero: '0379', // no last name given
|
|
||||||
lester: '8014',
|
|
||||||
nick: '6044',
|
|
||||||
david: '5993',
|
|
||||||
poalo: '9922', // likely "Paolo"
|
|
||||||
justin: '2329',
|
|
||||||
benstewart: '1957',
|
|
||||||
joseph: '4976',
|
|
||||||
francis: '4934',
|
|
||||||
chris: '1584', // Chris Mayor — admin
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reverse map: last4 → display name
|
|
||||||
export const GROOMSMEN_BY_PIN = Object.fromEntries(
|
|
||||||
Object.entries(GROOMSMEN).map(([name, pin]) => [pin, name])
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate a name + 4-digit PIN combination.
|
|
||||||
* Name must match a key in GROOMSMEN (case-insensitive).
|
|
||||||
* PIN must match that entry's value.
|
|
||||||
*/
|
|
||||||
export function validateGroomsman(name, pin) {
|
|
||||||
const key = name.trim().toLowerCase().replace(/\s+/g, '')
|
|
||||||
const entry = GROOMSMEN[key]
|
|
||||||
if (!entry) return false
|
|
||||||
return entry === pin.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the canonical display name for a validated groomsman.
|
|
||||||
*/
|
|
||||||
export function getCanonicalName(name) {
|
|
||||||
const key = name.trim().toLowerCase().replace(/\s+/g, '')
|
|
||||||
return GROOMSMEN[key] ? key : name.trim()
|
|
||||||
}
|
|
||||||
908
package-lock.json
generated
908
package-lock.json
generated
@@ -1,908 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "cabo-voting-app",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "cabo-voting-app",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"cors": "^2.8.5",
|
|
||||||
"express": "^4.21.2",
|
|
||||||
"uuid": "^11.1.0",
|
|
||||||
"ws": "^8.18.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/accepts": {
|
|
||||||
"version": "1.3.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
|
||||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"mime-types": "~2.1.34",
|
|
||||||
"negotiator": "0.6.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/array-flatten": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/body-parser": {
|
|
||||||
"version": "1.20.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
|
||||||
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"bytes": "~3.1.2",
|
|
||||||
"content-type": "~1.0.5",
|
|
||||||
"debug": "2.6.9",
|
|
||||||
"depd": "2.0.0",
|
|
||||||
"destroy": "~1.2.0",
|
|
||||||
"http-errors": "~2.0.1",
|
|
||||||
"iconv-lite": "~0.4.24",
|
|
||||||
"on-finished": "~2.4.1",
|
|
||||||
"qs": "~6.15.1",
|
|
||||||
"raw-body": "~2.5.3",
|
|
||||||
"type-is": "~1.6.18",
|
|
||||||
"unpipe": "~1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8",
|
|
||||||
"npm": "1.2.8000 || >= 1.4.16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/body-parser/node_modules/qs": {
|
|
||||||
"version": "6.15.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
|
||||||
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"dependencies": {
|
|
||||||
"side-channel": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/bytes": {
|
|
||||||
"version": "3.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
|
||||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/call-bind-apply-helpers": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"function-bind": "^1.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/call-bound": {
|
|
||||||
"version": "1.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
|
||||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
|
||||||
"get-intrinsic": "^1.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/content-disposition": {
|
|
||||||
"version": "0.5.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
|
||||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"safe-buffer": "5.2.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/content-type": {
|
|
||||||
"version": "1.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
|
||||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/cookie": {
|
|
||||||
"version": "0.7.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
|
||||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/cookie-signature": {
|
|
||||||
"version": "1.0.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
|
||||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/cors": {
|
|
||||||
"version": "2.8.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
|
||||||
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"object-assign": "^4",
|
|
||||||
"vary": "^1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.10"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/debug": {
|
|
||||||
"version": "2.6.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
|
||||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "2.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/depd": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/destroy": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8",
|
|
||||||
"npm": "1.2.8000 || >= 1.4.16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/dunder-proto": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bind-apply-helpers": "^1.0.1",
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"gopd": "^1.2.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ee-first": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/encodeurl": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/es-define-property": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/es-errors": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/es-object-atoms": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"es-errors": "^1.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/escape-html": {
|
|
||||||
"version": "1.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
|
||||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/etag": {
|
|
||||||
"version": "1.8.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
|
||||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/express": {
|
|
||||||
"version": "4.22.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
|
||||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"accepts": "~1.3.8",
|
|
||||||
"array-flatten": "1.1.1",
|
|
||||||
"body-parser": "~1.20.3",
|
|
||||||
"content-disposition": "~0.5.4",
|
|
||||||
"content-type": "~1.0.4",
|
|
||||||
"cookie": "~0.7.1",
|
|
||||||
"cookie-signature": "~1.0.6",
|
|
||||||
"debug": "2.6.9",
|
|
||||||
"depd": "2.0.0",
|
|
||||||
"encodeurl": "~2.0.0",
|
|
||||||
"escape-html": "~1.0.3",
|
|
||||||
"etag": "~1.8.1",
|
|
||||||
"finalhandler": "~1.3.1",
|
|
||||||
"fresh": "~0.5.2",
|
|
||||||
"http-errors": "~2.0.0",
|
|
||||||
"merge-descriptors": "1.0.3",
|
|
||||||
"methods": "~1.1.2",
|
|
||||||
"on-finished": "~2.4.1",
|
|
||||||
"parseurl": "~1.3.3",
|
|
||||||
"path-to-regexp": "~0.1.12",
|
|
||||||
"proxy-addr": "~2.0.7",
|
|
||||||
"qs": "~6.14.0",
|
|
||||||
"range-parser": "~1.2.1",
|
|
||||||
"safe-buffer": "5.2.1",
|
|
||||||
"send": "~0.19.0",
|
|
||||||
"serve-static": "~1.16.2",
|
|
||||||
"setprototypeof": "1.2.0",
|
|
||||||
"statuses": "~2.0.1",
|
|
||||||
"type-is": "~1.6.18",
|
|
||||||
"utils-merge": "1.0.1",
|
|
||||||
"vary": "~1.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.10.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/finalhandler": {
|
|
||||||
"version": "1.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
|
||||||
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"debug": "2.6.9",
|
|
||||||
"encodeurl": "~2.0.0",
|
|
||||||
"escape-html": "~1.0.3",
|
|
||||||
"on-finished": "~2.4.1",
|
|
||||||
"parseurl": "~1.3.3",
|
|
||||||
"statuses": "~2.0.2",
|
|
||||||
"unpipe": "~1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/forwarded": {
|
|
||||||
"version": "0.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
|
||||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fresh": {
|
|
||||||
"version": "0.5.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
|
||||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/function-bind": {
|
|
||||||
"version": "1.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
|
||||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/get-intrinsic": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
|
||||||
"es-define-property": "^1.0.1",
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"es-object-atoms": "^1.1.1",
|
|
||||||
"function-bind": "^1.1.2",
|
|
||||||
"get-proto": "^1.0.1",
|
|
||||||
"gopd": "^1.2.0",
|
|
||||||
"has-symbols": "^1.1.0",
|
|
||||||
"hasown": "^2.0.2",
|
|
||||||
"math-intrinsics": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/get-proto": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"dunder-proto": "^1.0.1",
|
|
||||||
"es-object-atoms": "^1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/gopd": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/has-symbols": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/hasown": {
|
|
||||||
"version": "2.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
|
||||||
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"function-bind": "^1.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/http-errors": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"depd": "~2.0.0",
|
|
||||||
"inherits": "~2.0.4",
|
|
||||||
"setprototypeof": "~1.2.0",
|
|
||||||
"statuses": "~2.0.2",
|
|
||||||
"toidentifier": "~1.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/iconv-lite": {
|
|
||||||
"version": "0.4.24",
|
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
|
||||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"safer-buffer": ">= 2.1.2 < 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/inherits": {
|
|
||||||
"version": "2.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/ipaddr.js": {
|
|
||||||
"version": "1.9.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
|
||||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/math-intrinsics": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/media-typer": {
|
|
||||||
"version": "0.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
|
||||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/merge-descriptors": {
|
|
||||||
"version": "1.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
|
||||||
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/methods": {
|
|
||||||
"version": "1.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
|
||||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mime": {
|
|
||||||
"version": "1.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
|
||||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"mime": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mime-db": {
|
|
||||||
"version": "1.52.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
|
||||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mime-types": {
|
|
||||||
"version": "2.1.35",
|
|
||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
|
||||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"mime-db": "1.52.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ms": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/negotiator": {
|
|
||||||
"version": "0.6.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
|
||||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/object-assign": {
|
|
||||||
"version": "4.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
|
||||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/object-inspect": {
|
|
||||||
"version": "1.13.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
|
||||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/on-finished": {
|
|
||||||
"version": "2.4.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
|
||||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ee-first": "1.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/parseurl": {
|
|
||||||
"version": "1.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
|
||||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/path-to-regexp": {
|
|
||||||
"version": "0.1.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
|
||||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/proxy-addr": {
|
|
||||||
"version": "2.0.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
|
||||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"forwarded": "0.2.0",
|
|
||||||
"ipaddr.js": "1.9.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/qs": {
|
|
||||||
"version": "6.14.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
|
||||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"dependencies": {
|
|
||||||
"side-channel": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/range-parser": {
|
|
||||||
"version": "1.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
|
||||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/raw-body": {
|
|
||||||
"version": "2.5.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
|
||||||
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"bytes": "~3.1.2",
|
|
||||||
"http-errors": "~2.0.1",
|
|
||||||
"iconv-lite": "~0.4.24",
|
|
||||||
"unpipe": "~1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/safe-buffer": {
|
|
||||||
"version": "5.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
|
||||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/safer-buffer": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/send": {
|
|
||||||
"version": "0.19.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
|
||||||
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"debug": "2.6.9",
|
|
||||||
"depd": "2.0.0",
|
|
||||||
"destroy": "1.2.0",
|
|
||||||
"encodeurl": "~2.0.0",
|
|
||||||
"escape-html": "~1.0.3",
|
|
||||||
"etag": "~1.8.1",
|
|
||||||
"fresh": "~0.5.2",
|
|
||||||
"http-errors": "~2.0.1",
|
|
||||||
"mime": "1.6.0",
|
|
||||||
"ms": "2.1.3",
|
|
||||||
"on-finished": "~2.4.1",
|
|
||||||
"range-parser": "~1.2.1",
|
|
||||||
"statuses": "~2.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/send/node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/serve-static": {
|
|
||||||
"version": "1.16.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
|
||||||
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"encodeurl": "~2.0.0",
|
|
||||||
"escape-html": "~1.0.3",
|
|
||||||
"parseurl": "~1.3.3",
|
|
||||||
"send": "~0.19.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/setprototypeof": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/side-channel": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"object-inspect": "^1.13.3",
|
|
||||||
"side-channel-list": "^1.0.0",
|
|
||||||
"side-channel-map": "^1.0.1",
|
|
||||||
"side-channel-weakmap": "^1.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/side-channel-list": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"object-inspect": "^1.13.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/side-channel-map": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bound": "^1.0.2",
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"get-intrinsic": "^1.2.5",
|
|
||||||
"object-inspect": "^1.13.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/side-channel-weakmap": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bound": "^1.0.2",
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"get-intrinsic": "^1.2.5",
|
|
||||||
"object-inspect": "^1.13.3",
|
|
||||||
"side-channel-map": "^1.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/statuses": {
|
|
||||||
"version": "2.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
|
||||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/toidentifier": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/type-is": {
|
|
||||||
"version": "1.6.18",
|
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
|
||||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"media-typer": "0.3.0",
|
|
||||||
"mime-types": "~2.1.24"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/unpipe": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/utils-merge": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/uuid": {
|
|
||||||
"version": "11.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
|
|
||||||
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
|
|
||||||
"funding": [
|
|
||||||
"https://github.com/sponsors/broofa",
|
|
||||||
"https://github.com/sponsors/ctavan"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"uuid": "dist/esm/bin/uuid"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vary": {
|
|
||||||
"version": "1.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
|
||||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ws": {
|
|
||||||
"version": "8.20.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
|
||||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"bufferutil": "^4.0.1",
|
|
||||||
"utf-8-validate": ">=5.0.2"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"bufferutil": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"utf-8-validate": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
19
package.json
19
package.json
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "cabo-voting-app",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"description": "Real-time Cabo bachelor party voting with budgets, packages, and activity planning.",
|
|
||||||
"main": "server.js",
|
|
||||||
"scripts": {
|
|
||||||
"start": "node server.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"cors": "^2.8.5",
|
|
||||||
"express": "^4.21.2",
|
|
||||||
"uuid": "^11.1.0",
|
|
||||||
"ws": "^8.18.2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1
price-watch/.gitignore
vendored
1
price-watch/.gitignore
vendored
@@ -1 +0,0 @@
|
|||||||
latest-report.md
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,39 +0,0 @@
|
|||||||
# Cabo Price Watch - 2026-06-10
|
|
||||||
|
|
||||||
Trip window: 2027-02-03 through 2027-02-07 (4 nights)
|
|
||||||
|
|
||||||
## Biggest Changes
|
|
||||||
|
|
||||||
- CheapCaribbean's current Los Cabos package ladder is lower across the main RIU/Breathless/Secrets set than the prior run: Riu Santa Fe is now $784 pp, Riu Palace Baja California $997 pp, Dreams $895 pp, Riu Palace Cabo San Lucas $941 pp, Secrets $1,075 pp, and Breathless $1,017 pp.
|
|
||||||
- Apple Vacations' Los Cabos package floors are higher than the last pass: Riu Palace Baja California is $841 pp, Dreams $874 pp, Pueblo Bonito Los Cabos Beach Resort $879 pp, Sandos Finisterra $911 pp, Breathless $993 pp, Paradisus $1,152 pp, and Grand Velas $2,544 pp for 3 nights.
|
|
||||||
- KAYAK's route-floor signals moved down: LAX-SJD shows a $320 round-trip floor and ONT-SJD shows a $302 round-trip floor.
|
|
||||||
- Current hotel-only floors remain in place or moved only slightly: Breathless $381/night, Grand Fiesta Americana $212/night, Secrets $305/night, Hard Rock $277/night, Nobu $294/night, Solmar $84/night, Hyatt Ziva $314/night, Corazon $68/night, and Grand Velas $1,135/night.
|
|
||||||
- Quivira's current winter pricing is lower than the prior anchor for the Feb trip window: twilight is now $275, with Palmilla still visible at $252.89 for a 18-hole morning rate and Cabo San Lucas Country Club at $183.60 twilight.
|
|
||||||
- Cabo Bash now exposes a deeper yacht ladder and more clear bachelor-party activity pricing, including 32ft Sea Ray $230/hour, 34ft Bayliner $360/hour, 44ft Sea Ray $450/hour, 75ft Sunseeker $2,300/hour, Sunset Booze & Dinner Cruise $95 pp, and ATV / off-road options from $140-$190 pp.
|
|
||||||
|
|
||||||
## Stable Anchors
|
|
||||||
|
|
||||||
- No sold-out items were visible in this pass.
|
|
||||||
- The cheapest current standalone hotel floor remains Corazon at $68/night.
|
|
||||||
- The most useful lower-mid hotel anchors remain Solmar at $84/night and Grand Fiesta Americana at $212/night.
|
|
||||||
- The standalone golf baseline still has a cheap end and a premium end: Cabo San Lucas Country Club at $183.60 and Quivira at $275 twilight for the target-season comparator.
|
|
||||||
|
|
||||||
## Missing Or Gated
|
|
||||||
|
|
||||||
- Costco Travel remains login/continue-gated for the exact Cabo package flow.
|
|
||||||
- Apple and CheapCaribbean exposed live package floors, but the page snippets showed best-priced departure dates rather than a target-trip Feb 3-7, 2027 quote.
|
|
||||||
- The current browser surface did not expose a date-matched Google Flights quote in this pass, so flights are represented by the live KAYAK route-floor signals.
|
|
||||||
|
|
||||||
## New Options Worth Adding
|
|
||||||
|
|
||||||
- Cabo Bash 32ft Sea Ray, 44ft Sea Ray, 50ft Azimut, 60ft Azimut, 75ft Sunseeker, 80ft Sunseeker Manhattan, 80ft Ferretti, and 100ft Maiora yacht cards.
|
|
||||||
- Cabo Bash Sunset Booze & Dinner Cruise, Luxury Sunset Sailing, Flyboard, Jetboard, Jet Skis, Snorkel/SUP/Kayak combo, Whale Shark encounter, and Camel Ride / ATV / Zip Line / Electric Bike adventure cards.
|
|
||||||
- The Cabo Agency La Vaquita booth and tiered table options should stay visible alongside Mandala, Taboo, and El Squid Roe.
|
|
||||||
|
|
||||||
## Budget Impact
|
|
||||||
|
|
||||||
- Budget path is now about $1,126.60 pp or $9,012.80 for 8 using Riu Santa Fe, Country Club, the electric-bike adventure, and a no-cost Bagatelle reservation.
|
|
||||||
- Balanced path is about $1,500.00 pp or $15,000.00 for 10 using Breathless, Quivira, the 34ft Bayliner, and a La Vaquita platinum table.
|
|
||||||
- Splurge path is about $3,570.83 pp or $42,849.96 for 12 using Grand Velas, Quivira, the 75ft Sunseeker, and a Taboo cabana.
|
|
||||||
- Package-vs-standalone caveat: package prices already bundle flight and hotel, so separate flight or hotel costs should not be added on top unless the package explicitly excludes them.
|
|
||||||
- Relative to the prior run, budget and balanced are lower, while splurge is effectively flat because the higher Grand Velas package floor offsets the cheaper Quivira and nightlife/yacht inputs.
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
{
|
|
||||||
"reporting": {
|
|
||||||
"latestReport": "price-watch/latest-report.md",
|
|
||||||
"historyLog": "price-watch/history.jsonl"
|
|
||||||
},
|
|
||||||
"comparison": {
|
|
||||||
"materialPriceChangeUsd": 100,
|
|
||||||
"highlightNewOptions": true,
|
|
||||||
"markLoginRequiredSources": true
|
|
||||||
},
|
|
||||||
"tripDates": {
|
|
||||||
"checkIn": "2027-02-03",
|
|
||||||
"checkOut": "2027-02-07",
|
|
||||||
"nights": 4,
|
|
||||||
"note": "All per-night or per-day rates should be converted to the full check-in/check-out total for comparison, while preserving the unit rate in the display label."
|
|
||||||
},
|
|
||||||
"trackedSources": [
|
|
||||||
{
|
|
||||||
"id": "hotel-packages",
|
|
||||||
"label": "Flight + Hotel Packages",
|
|
||||||
"categories": ["hotel"],
|
|
||||||
"bookingType": "package",
|
|
||||||
"requiredChecks": [
|
|
||||||
"Costco Travel package results",
|
|
||||||
"Apple Vacations package search",
|
|
||||||
"CheapCaribbean package search",
|
|
||||||
"other date-matched package providers found during research"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "standalone-hotels",
|
|
||||||
"label": "Standalone Hotels",
|
|
||||||
"categories": ["hotel"],
|
|
||||||
"bookingType": "standalone",
|
|
||||||
"requiredChecks": [
|
|
||||||
"KAYAK hotel search",
|
|
||||||
"official hotel booking engine when public rates are visible",
|
|
||||||
"other OTA hotel-only rates found during research"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "flights",
|
|
||||||
"label": "Standalone Flights",
|
|
||||||
"categories": ["flight"],
|
|
||||||
"bookingType": "standalone",
|
|
||||||
"requiredChecks": [
|
|
||||||
"LAX to SJD date-matched round trip on airline and travel sites",
|
|
||||||
"ONT to SJD date-matched round trip on airline and travel sites",
|
|
||||||
"Google Flights exact-date search",
|
|
||||||
"KAYAK exact-date flight search",
|
|
||||||
"airline direct-booking results when publicly visible",
|
|
||||||
"capture airline, stops, schedule window, baggage caveats, and total price per traveler"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "golf",
|
|
||||||
"label": "Golf",
|
|
||||||
"categories": ["golf"],
|
|
||||||
"bookingType": "standalone",
|
|
||||||
"requiredChecks": [
|
|
||||||
"official course tee-time pages when available",
|
|
||||||
"public tee-time marketplaces",
|
|
||||||
"resort/package golf inclusions when attached to hotel packages"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "nightlife",
|
|
||||||
"label": "Nightlife and Day Clubs",
|
|
||||||
"categories": ["nightlife"],
|
|
||||||
"bookingType": "standalone",
|
|
||||||
"requiredChecks": [
|
|
||||||
"VIP table packages",
|
|
||||||
"bottle service minimums",
|
|
||||||
"day-club and beach-club package pricing",
|
|
||||||
"cover charges or ticketed events when visible"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "excursions",
|
|
||||||
"label": "Excursions and Water Activities",
|
|
||||||
"categories": ["excursion"],
|
|
||||||
"bookingType": "standalone",
|
|
||||||
"requiredChecks": [
|
|
||||||
"yacht and private charter quotes",
|
|
||||||
"whale-watch and sunset sail pricing",
|
|
||||||
"ATV/off-road packages",
|
|
||||||
"bachelor-party-relevant group excursions"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "derived-itineraries",
|
|
||||||
"label": "Derived Itineraries",
|
|
||||||
"categories": ["itinerary"],
|
|
||||||
"bookingType": "calculated",
|
|
||||||
"requiredChecks": [
|
|
||||||
"recalculate budget, balanced, and splurge itinerary totals from the current hotel/package, flight, golf, nightlife, and excursion results",
|
|
||||||
"include component breakdowns and assumptions for each itinerary"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "derived-budgets",
|
|
||||||
"label": "Derived Budget Tracks",
|
|
||||||
"categories": ["budget"],
|
|
||||||
"bookingType": "calculated",
|
|
||||||
"requiredChecks": [
|
|
||||||
"recalculate 8, 10, and 12 person totals from current results",
|
|
||||||
"prefer exact package prices when the itinerary uses a flight+hotel package",
|
|
||||||
"avoid double-counting flights or hotels already included in package prices",
|
|
||||||
"include per-person and group-total math plus assumptions"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"bookingTypeRules": {
|
|
||||||
"package": "Use for bundled products such as flight+hotel packages or hotel+transfer packages. Include included components and do not mix directly with standalone room-only rates.",
|
|
||||||
"standalone": "Use for individual bookings such as hotel-only rates, flights, golf tee times, nightlife tables, yacht charters, and excursions.",
|
|
||||||
"calculated": "Use for automation-derived itinerary and budget totals built from current package or standalone components."
|
|
||||||
},
|
|
||||||
"outputSchema": {
|
|
||||||
"optionPrices": [
|
|
||||||
{
|
|
||||||
"seedKey": "stable app option key when available",
|
|
||||||
"price": "numeric price only, or null when unavailable",
|
|
||||||
"displayLabel": "human-readable price label",
|
|
||||||
"category": "hotel | flight | golf | nightlife | excursion | itinerary | budget",
|
|
||||||
"source": "travel site or vendor",
|
|
||||||
"sourceUrl": "exact result or source URL",
|
|
||||||
"bookingType": "package | standalone | calculated",
|
|
||||||
"priceBasis": "perTraveler | perNight | perDay | perPerson | perGroup | totalPackage | perRound | perTable",
|
|
||||||
"unitPrice": "original unit price when source quotes per night or per day",
|
|
||||||
"tripTotalPrice": "calculated full-stay/check-in-to-check-out price when source quotes per night or per day",
|
|
||||||
"includedComponents": ["flight", "hotel", "transfer", "golf", "nightlife", "excursion"],
|
|
||||||
"excludedComponents": ["components that must be budgeted separately"],
|
|
||||||
"origin": "airport code for flight/package quotes when applicable",
|
|
||||||
"destination": "airport or destination code when applicable",
|
|
||||||
"availability": "available | unavailable | sold-out | login-required | request-quote",
|
|
||||||
"features": ["structured decision features"],
|
|
||||||
"amenities": ["hotel or venue amenities"],
|
|
||||||
"inclusions": ["included items"],
|
|
||||||
"limitations": ["tradeoffs, caveats, restrictions"],
|
|
||||||
"decisionNote": "short decision note"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"derivedItineraries": [
|
|
||||||
{
|
|
||||||
"seedKey": "itinerary-budget | itinerary-balanced | itinerary-splurge | itinerary-concierge or new stable key",
|
|
||||||
"tier": "Budget | Balanced | Splurge | Concierge",
|
|
||||||
"perPerson": "numeric calculated per-person total",
|
|
||||||
"groupTotal": "numeric calculated group total when group size is known",
|
|
||||||
"groupSize": "8 | 10 | 12 or selected party size",
|
|
||||||
"components": ["component price keys used"],
|
|
||||||
"assumptions": ["calculation assumptions"],
|
|
||||||
"summary": "short recommendation summary"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"budgetScenarios": [
|
|
||||||
{
|
|
||||||
"id": "stable scenario id",
|
|
||||||
"tier": "Budget | Balanced | Splurge",
|
|
||||||
"groupSize": "numeric group size",
|
|
||||||
"perPerson": "numeric calculated per-person total",
|
|
||||||
"groupTotal": "numeric calculated group total",
|
|
||||||
"summary": "short scenario summary",
|
|
||||||
"notes": ["component breakdown and assumptions"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"notes": [
|
|
||||||
"Use seed-data.js as the current baseline for names, links, and budget assumptions.",
|
|
||||||
"Check hotels, flights, golf, nightlife, and excursions on every run before updating itinerary or budget recommendations.",
|
|
||||||
"For flights, search both airline direct-booking pages and travel aggregators such as Google Flights, KAYAK, Expedia, and similar public flight search tools when available.",
|
|
||||||
"Differentiate bundled package prices from standalone booking prices using bookingType and priceBasis on every price point.",
|
|
||||||
"For package quotes, list the included and excluded components so budgets do not double-count flights, hotels, transfers, or resort credits.",
|
|
||||||
"For standalone quotes, list the exact unit being priced: per night, per traveler, per person, per group, per round, or per table.",
|
|
||||||
"For per-night or per-day hotel/activity rates, calculate the total for the expected check-in/check-out dates and preserve the unit rate in unitPrice or displayLabel.",
|
|
||||||
"Itinerary and budget options are calculated outputs. Recompute them from the freshest current package and standalone component prices instead of treating seed-data.js totals as current.",
|
|
||||||
"Write a human-readable report to price-watch/latest-report.md on every run.",
|
|
||||||
"Append one machine-readable summary line per run to price-watch/history.jsonl, including per-option price points, derivedItineraries, and budgetScenarios keyed by stable option ids or seed keys.",
|
|
||||||
"Capture structured option details when available: current price, availability, source, sourceUrl, highlights, features, amenities, inclusions, limitations, and a short decision note.",
|
|
||||||
"If a source is gated behind login or membership, note that clearly in both outputs."
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -120,87 +120,8 @@
|
|||||||
.btn-reject { background: var(--red); color: #fff; }
|
.btn-reject { background: var(--red); color: #fff; }
|
||||||
.btn-delete { background: transparent; border: 1px solid var(--border); color: var(--text-muted); }
|
.btn-delete { background: transparent; border: 1px solid var(--border); color: var(--text-muted); }
|
||||||
.btn-delete:hover { border-color: var(--red); color: var(--red); }
|
.btn-delete:hover { border-color: var(--red); color: var(--red); }
|
||||||
.btn-edit { background: transparent; border: 1px solid var(--border); color: var(--amber); }
|
|
||||||
.btn-edit:hover { border-color: var(--amber); color: #fcd34d; }
|
|
||||||
.btn-row { display: flex; gap: 6px; flex-shrink: 0; }
|
.btn-row { display: flex; gap: 6px; flex-shrink: 0; }
|
||||||
|
|
||||||
/* Editor */
|
|
||||||
.editor-card {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 18px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
.editor-card.hidden { display: none; }
|
|
||||||
.editor-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 14px;
|
|
||||||
}
|
|
||||||
.editor-grid .full { grid-column: 1 / -1; }
|
|
||||||
.editor-card label {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.4px;
|
|
||||||
}
|
|
||||||
.editor-card input,
|
|
||||||
.editor-card select,
|
|
||||||
.editor-card textarea {
|
|
||||||
width: 100%;
|
|
||||||
background: var(--surface2);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
color: var(--text);
|
|
||||||
padding: 10px 12px;
|
|
||||||
font-size: 0.88rem;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.editor-card textarea {
|
|
||||||
min-height: 110px;
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
.editor-card input:focus,
|
|
||||||
.editor-card select:focus,
|
|
||||||
.editor-card textarea:focus {
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
.editor-meta {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
.checkbox-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--text);
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
|
||||||
.checkbox-row input { width: auto; }
|
|
||||||
.editor-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-top: 14px;
|
|
||||||
}
|
|
||||||
.btn-secondary {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
.btn-secondary:hover {
|
|
||||||
border-color: var(--text-muted);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toast */
|
/* Toast */
|
||||||
.toast {
|
.toast {
|
||||||
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(80px);
|
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(80px);
|
||||||
@@ -219,8 +140,6 @@
|
|||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.option-row { flex-wrap: wrap; }
|
.option-row { flex-wrap: wrap; }
|
||||||
.btn-row { width: 100%; justify-content: flex-end; }
|
.btn-row { width: 100%; justify-content: flex-end; }
|
||||||
.editor-grid { grid-template-columns: 1fr; }
|
|
||||||
.editor-actions { justify-content: stretch; }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -266,44 +185,6 @@
|
|||||||
<button class="btn-toggle open" id="pollsBtn" onclick="togglePolls()">Close Polls</button>
|
<button class="btn-toggle open" id="pollsBtn" onclick="togglePolls()">Close Polls</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="editor-card hidden" id="editorCard">
|
|
||||||
<div class="section-header">
|
|
||||||
<span class="section-title">Edit Option</span>
|
|
||||||
<span class="badge" id="editorStatus">Draft</span>
|
|
||||||
</div>
|
|
||||||
<div class="editor-meta" id="editorMeta">Select an option to edit.</div>
|
|
||||||
<div class="editor-grid">
|
|
||||||
<label>
|
|
||||||
Name
|
|
||||||
<input id="editorName" type="text" maxlength="80" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Category
|
|
||||||
<select id="editorCategory"></select>
|
|
||||||
</label>
|
|
||||||
<label class="full">
|
|
||||||
Description
|
|
||||||
<input id="editorDesc" type="text" maxlength="200" />
|
|
||||||
</label>
|
|
||||||
<label class="full">
|
|
||||||
Details
|
|
||||||
<textarea id="editorDetails" placeholder="One detail per line"></textarea>
|
|
||||||
</label>
|
|
||||||
<label class="full">
|
|
||||||
Website URL
|
|
||||||
<input id="editorUrl" type="url" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label class="checkbox-row">
|
|
||||||
<input id="editorApproved" type="checkbox" />
|
|
||||||
Approve this option
|
|
||||||
</label>
|
|
||||||
<div class="editor-actions">
|
|
||||||
<button class="btn btn-secondary" onclick="closeEditor()">Cancel</button>
|
|
||||||
<button class="btn btn-approve" onclick="saveEditor()">Save changes</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pending Options -->
|
<!-- Pending Options -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
@@ -330,7 +211,6 @@ const API = '';
|
|||||||
const PWD_KEY = 'cabo_admin_pwd';
|
const PWD_KEY = 'cabo_admin_pwd';
|
||||||
const CORRECT_PWD = 'cabo2026';
|
const CORRECT_PWD = 'cabo2026';
|
||||||
let allData = null;
|
let allData = null;
|
||||||
let editingOptionId = null;
|
|
||||||
|
|
||||||
function toast(msg, type='') {
|
function toast(msg, type='') {
|
||||||
const t = document.getElementById('toast');
|
const t = document.getElementById('toast');
|
||||||
@@ -360,7 +240,6 @@ async function loadData() {
|
|||||||
fetch(API + '/api/options?includeUnapproved=true').then(r => r.json()),
|
fetch(API + '/api/options?includeUnapproved=true').then(r => r.json()),
|
||||||
]);
|
]);
|
||||||
allData = { categories: cats, options: opts };
|
allData = { categories: cats, options: opts };
|
||||||
renderEditorCategoryOptions();
|
|
||||||
renderStats();
|
renderStats();
|
||||||
renderPending();
|
renderPending();
|
||||||
renderAll();
|
renderAll();
|
||||||
@@ -370,14 +249,6 @@ async function loadData() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderEditorCategoryOptions() {
|
|
||||||
const select = document.getElementById('editorCategory');
|
|
||||||
select.innerHTML = (allData?.categories || [])
|
|
||||||
.filter(category => category.id !== 'results' && category.id !== 'map')
|
|
||||||
.map(category => `<option value="${category.id}">${category.name}</option>`)
|
|
||||||
.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderStats() {
|
function renderStats() {
|
||||||
const voters = new Set();
|
const voters = new Set();
|
||||||
let totalVotes = 0;
|
let totalVotes = 0;
|
||||||
@@ -407,7 +278,6 @@ function renderPending() {
|
|||||||
<div class="name">${o.name}</div>
|
<div class="name">${o.name}</div>
|
||||||
<div class="meta">by ${o.addedBy || 'unknown'}</div>
|
<div class="meta">by ${o.addedBy || 'unknown'}</div>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button class="btn btn-edit" onclick="editOption('${o.id}')">✎ Edit</button>
|
|
||||||
<button class="btn btn-approve" onclick="approve('${o.id}')">✓ Approve</button>
|
<button class="btn btn-approve" onclick="approve('${o.id}')">✓ Approve</button>
|
||||||
<button class="btn btn-reject" onclick="reject('${o.id}')">✕ Reject</button>
|
<button class="btn btn-reject" onclick="reject('${o.id}')">✕ Reject</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -425,56 +295,12 @@ function renderAll() {
|
|||||||
<div class="votes-badge">${o.votes.length} vote${o.votes.length !== 1 ? 's' : ''}</div>
|
<div class="votes-badge">${o.votes.length} vote${o.votes.length !== 1 ? 's' : ''}</div>
|
||||||
<div class="meta">${o.addedBy !== 'system' ? 'by ' + o.addedBy : 'system'}</div>
|
<div class="meta">${o.addedBy !== 'system' ? 'by ' + o.addedBy : 'system'}</div>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button class="btn btn-edit" onclick="editOption('${o.id}')">✎ Edit</button>
|
|
||||||
<button class="btn btn-delete" onclick="deleteOption('${o.id}')" title="Delete option">🗑</button>
|
<button class="btn btn-delete" onclick="deleteOption('${o.id}')" title="Delete option">🗑</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function editOption(id) {
|
|
||||||
const current = allData.options.find(o => o.id === id);
|
|
||||||
if (!current) return;
|
|
||||||
editingOptionId = id;
|
|
||||||
document.getElementById('editorCard').classList.remove('hidden');
|
|
||||||
document.getElementById('editorName').value = current.name || '';
|
|
||||||
document.getElementById('editorCategory').value = current.categoryId || '';
|
|
||||||
document.getElementById('editorDesc').value = current.desc || '';
|
|
||||||
document.getElementById('editorDetails').value = Array.isArray(current.details) ? current.details.join('\n') : '';
|
|
||||||
document.getElementById('editorUrl').value = current.url || '';
|
|
||||||
document.getElementById('editorApproved').checked = Boolean(current.approved);
|
|
||||||
document.getElementById('editorStatus').textContent = current.approved ? 'Approved' : 'Pending';
|
|
||||||
document.getElementById('editorMeta').textContent = `${current.name} • ${current.categoryId} • added by ${current.addedBy || 'unknown'}`;
|
|
||||||
document.getElementById('editorName').focus();
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeEditor() {
|
|
||||||
editingOptionId = null;
|
|
||||||
document.getElementById('editorCard').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveEditor() {
|
|
||||||
if (!editingOptionId) return;
|
|
||||||
try {
|
|
||||||
await fetch(API + '/api/options/' + editingOptionId, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: document.getElementById('editorName').value.trim(),
|
|
||||||
desc: document.getElementById('editorDesc').value.trim(),
|
|
||||||
details: document.getElementById('editorDetails').value,
|
|
||||||
url: document.getElementById('editorUrl').value.trim(),
|
|
||||||
categoryId: document.getElementById('editorCategory').value,
|
|
||||||
approved: document.getElementById('editorApproved').checked,
|
|
||||||
})
|
|
||||||
});
|
|
||||||
toast('Option updated', 'success');
|
|
||||||
closeEditor();
|
|
||||||
await loadData();
|
|
||||||
} catch(e) { toast('Failed to update option', 'error'); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function togglePolls() {
|
async function togglePolls() {
|
||||||
try {
|
try {
|
||||||
// Get current state first
|
// Get current state first
|
||||||
|
|||||||
1857
public/index.html
1857
public/index.html
File diff suppressed because it is too large
Load Diff
232
seed-data.js
232
seed-data.js
@@ -1,9 +1,8 @@
|
|||||||
const SEED_VERSION = 7;
|
const SEED_VERSION = 2;
|
||||||
const PRICE_UPDATED_AT = '2026-05-01';
|
const PRICE_UPDATED_AT = '2026-04-29';
|
||||||
|
|
||||||
const CATEGORY_META = {
|
const CATEGORY_META = {
|
||||||
hotel: { emoji: '🏨', color: '#3b82f6' },
|
hotel: { emoji: '🏨', color: '#3b82f6' },
|
||||||
flight: { emoji: '✈️', color: '#38bdf8' },
|
|
||||||
golf: { emoji: '⛳', color: '#22c55e' },
|
golf: { emoji: '⛳', color: '#22c55e' },
|
||||||
nightlife: { emoji: '🎧', color: '#a855f7' },
|
nightlife: { emoji: '🎧', color: '#a855f7' },
|
||||||
excursion: { emoji: '🚤', color: '#06b6d4' },
|
excursion: { emoji: '🚤', color: '#06b6d4' },
|
||||||
@@ -12,23 +11,6 @@ const CATEGORY_META = {
|
|||||||
results: { emoji: '🏆', color: '#facc15' },
|
results: { emoji: '🏆', color: '#facc15' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const GUEST_ROSTER = [
|
|
||||||
{ name: 'Jon', last4: '7506', role: 'groom' },
|
|
||||||
{ name: 'Toph', last4: '8116', role: 'best-man' },
|
|
||||||
{ name: 'Hans', last4: '6681', role: 'guest' },
|
|
||||||
{ name: 'Janno', last4: '2809', role: 'guest' },
|
|
||||||
{ name: 'JT', last4: '3286', role: 'guest' },
|
|
||||||
{ name: 'Cordero', last4: '0379', role: 'guest' },
|
|
||||||
{ name: 'Lester', last4: '8014', role: 'guest' },
|
|
||||||
{ name: 'Nick', last4: '6044', role: 'guest' },
|
|
||||||
{ name: 'David', last4: '5993', role: 'guest' },
|
|
||||||
{ name: 'Poalo', last4: '9922', role: 'guest' },
|
|
||||||
{ name: 'Justin', last4: '2329', role: 'guest' },
|
|
||||||
{ name: 'Ben Stewart', last4: '1957', role: 'guest' },
|
|
||||||
{ name: 'Joseph', last4: '4976', role: 'guest' },
|
|
||||||
{ name: 'Francis', last4: '4934', role: 'guest' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const BUDGET_SCENARIOS = [
|
const BUDGET_SCENARIOS = [
|
||||||
{
|
{
|
||||||
id: 'budget-8',
|
id: 'budget-8',
|
||||||
@@ -199,7 +181,6 @@ function buildSeedData() {
|
|||||||
priceUpdatedAt: PRICE_UPDATED_AT,
|
priceUpdatedAt: PRICE_UPDATED_AT,
|
||||||
categories: [
|
categories: [
|
||||||
{ id: 'hotel', name: 'Hotels', emoji: '🏨' },
|
{ id: 'hotel', name: 'Hotels', emoji: '🏨' },
|
||||||
{ id: 'flight', name: 'Flights', emoji: '✈️' },
|
|
||||||
{ id: 'golf', name: 'Golf', emoji: '⛳' },
|
{ id: 'golf', name: 'Golf', emoji: '⛳' },
|
||||||
{ id: 'nightlife', name: 'Nightlife', emoji: '🎧' },
|
{ id: 'nightlife', name: 'Nightlife', emoji: '🎧' },
|
||||||
{ id: 'excursion', name: 'Excursions', emoji: '🚤' },
|
{ id: 'excursion', name: 'Excursions', emoji: '🚤' },
|
||||||
@@ -207,7 +188,6 @@ function buildSeedData() {
|
|||||||
{ id: 'budget', name: 'Budget', emoji: '💸' },
|
{ id: 'budget', name: 'Budget', emoji: '💸' },
|
||||||
{ id: 'results', name: 'Results', emoji: '🏆' },
|
{ id: 'results', name: 'Results', emoji: '🏆' },
|
||||||
],
|
],
|
||||||
guestRoster: GUEST_ROSTER,
|
|
||||||
budgetScenarios: BUDGET_SCENARIOS,
|
budgetScenarios: BUDGET_SCENARIOS,
|
||||||
options: [
|
options: [
|
||||||
createOption({
|
createOption({
|
||||||
@@ -218,7 +198,7 @@ function buildSeedData() {
|
|||||||
desc: 'Best party-first base on Medano Beach. Walkable to downtown and Costco package pages currently show transfer-inclusive offers plus 4th or 5th night promos.',
|
desc: 'Best party-first base on Medano Beach. Walkable to downtown and Costco package pages currently show transfer-inclusive offers plus 4th or 5th night promos.',
|
||||||
lat: 23.0639,
|
lat: 23.0639,
|
||||||
lng: -109.6991,
|
lng: -109.6991,
|
||||||
details: ['Costco package availability only', 'KAYAK no fresh rates', 'Walk to marina nightlife'],
|
details: ['KAYAK recent rooms $173-$551/night', 'Costco package', 'Walk to marina nightlife'],
|
||||||
links: [
|
links: [
|
||||||
{ label: 'Official', url: 'https://www.corazoncabo.com/' },
|
{ label: 'Official', url: 'https://www.corazoncabo.com/' },
|
||||||
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSCORAZON20230510' },
|
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSCORAZON20230510' },
|
||||||
@@ -233,12 +213,12 @@ function buildSeedData() {
|
|||||||
desc: 'Adults-only, marina-facing, and the easiest all-inclusive pick for a true bachelor-party vibe.',
|
desc: 'Adults-only, marina-facing, and the easiest all-inclusive pick for a true bachelor-party vibe.',
|
||||||
lat: 23.0628,
|
lat: 23.0628,
|
||||||
lng: -109.6981,
|
lng: -109.6981,
|
||||||
details: ['Apple exact-date quote: $2,016 pp', 'Costco package: $1,678.99 pp', 'Adults-only'],
|
details: ['Apple Vacations from $942 pp / 3 nights', 'KAYAK from $393/night', 'Adults-only'],
|
||||||
links: [
|
links: [
|
||||||
{ label: 'Official', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/breathless/mexico/cabo-san-lucas-resort-spa/' },
|
{ label: 'Official', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/breathless/mexico/cabo-san-lucas-resort-spa/' },
|
||||||
{ label: 'Hyatt', url: 'https://www.hyatt.com/en-US/hotel/mexico/breathless-cabo-san-lucas/brcsl' },
|
{ label: 'Hyatt', url: 'https://www.hyatt.com/en-US/hotel/mexico/breathless-cabo-san-lucas/brcsl' },
|
||||||
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSBREATHE20230330' },
|
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSBREATHE20230330' },
|
||||||
{ label: 'Apple Vacations', url: 'https://www.applevacations.com/hotels/breathless-cabo-san-lucas-resort-spa-all-inclusive-adults-only?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&los=4&mode=0&onsaleid=1398047&traveldate=2027-02-03' },
|
{ label: 'Apple Vacations', url: 'https://www.applevacations.com/hotels/breathless-cabo-san-lucas-resort-spa-all-inclusive-adults-only?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&los=3&mode=0&onsaleid=1398047&traveldate=2026-05-10' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
createOption({
|
createOption({
|
||||||
@@ -249,11 +229,11 @@ function buildSeedData() {
|
|||||||
desc: 'Best overall balance for golf + all-inclusive + quality. Strong fit if the group wants one easy answer without going full splurge.',
|
desc: 'Best overall balance for golf + all-inclusive + quality. Strong fit if the group wants one easy answer without going full splurge.',
|
||||||
lat: 23.0949,
|
lat: 23.0949,
|
||||||
lng: -109.7067,
|
lng: -109.7067,
|
||||||
details: ['Apple exact-date quote: $2,111 pp', 'KAYAK from $212/night', 'Golf-friendly'],
|
details: ['Apple Vacations from $859 pp / 3 nights', 'KAYAK from $209/night', 'Golf-friendly'],
|
||||||
links: [
|
links: [
|
||||||
{ label: 'Official', url: 'https://www.fiestamericanatravelty.com/en/grand-fiesta-americana/hotels/grand-fiesta-americana-los-cabos-all-inclusive-golf-and-spa' },
|
{ label: 'Official', url: 'https://www.fiestamericana.com/en/hotels/grand-fiesta-americana-los-cabos' },
|
||||||
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSGRANDFIESTA20171011' },
|
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSGRANDFIESTA20171011' },
|
||||||
{ label: 'Apple Vacations', url: 'https://www.applevacations.com/HotelById?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&hotelcode=43000&los=4&mode=0&onsaleid=1614780&redirect=false&remotesourcecode=HBSHotel&traveldate=2027-02-03&vendorcode=APV' },
|
{ label: 'Apple Vacations', url: 'https://www.applevacations.com/HotelById?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&hotelcode=43000&los=3&mode=0&onsaleid=1614780&redirect=false&remotesourcecode=HBSHotel&traveldate=&vendorcode=APV' },
|
||||||
{ label: 'KAYAK', url: 'https://www.kayak.com/Cabo-San-Lucas-Hotels-Grand-Fiesta-Americana-Los-Cabos-Golf-Spa.331383.ksp' },
|
{ label: 'KAYAK', url: 'https://www.kayak.com/Cabo-San-Lucas-Hotels-Grand-Fiesta-Americana-Los-Cabos-Golf-Spa.331383.ksp' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
@@ -265,11 +245,11 @@ function buildSeedData() {
|
|||||||
desc: 'Upscale adults-only pick with strong group-trip polish. Better for a luxe weekend than a chaos-first party hotel.',
|
desc: 'Upscale adults-only pick with strong group-trip polish. Better for a luxe weekend than a chaos-first party hotel.',
|
||||||
lat: 23.0227,
|
lat: 23.0227,
|
||||||
lng: -109.7062,
|
lng: -109.7062,
|
||||||
details: ['KAYAK exact-date room rate: $335/night', 'Costco package: $2,005.80 pp', 'Adults-only'],
|
details: ['CheapCaribbean from $885 pp / 3 nights', '4-night examples from $1,108 pp', 'Adults-only'],
|
||||||
links: [
|
links: [
|
||||||
{ label: 'Official', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/secrets/mexico/puerto-los-cabos-golf-spa-resort/' },
|
{ label: 'Official', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/secrets/mexico/puerto-los-cabos-golf-spa-resort/' },
|
||||||
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSSECRETSPUERT20230330' },
|
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSSECRETSPUERT20230330' },
|
||||||
{ label: 'CheapCaribbean', url: 'https://www.cheapcaribbean.com/HotelById/?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&hotelcode=SJDSCRT&los=4&mode=0&onsaleid=2173329&redirect=false&remotesourcecode=LtmsHotel&traveldate=2027-02-03&vendorcode=CCV' },
|
{ label: 'CheapCaribbean', url: 'https://www.cheapcaribbean.com/HotelById/?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&hotelcode=SJDSCRT&los=3&mode=0&onsaleid=2173329&redirect=false&remotesourcecode=LtmsHotel&traveldate=&vendorcode=CCV' },
|
||||||
{ label: 'KAYAK', url: 'https://www.kayak.com/San-Jose-del-Cabo-Hotels-Secrets-Puerto-Los-Cabos-Adults-Only.551846.ksp' },
|
{ label: 'KAYAK', url: 'https://www.kayak.com/San-Jose-del-Cabo-Hotels-Secrets-Puerto-Los-Cabos-Adults-Only.551846.ksp' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
@@ -287,193 +267,6 @@ function buildSeedData() {
|
|||||||
{ label: 'Quivira FAQ', url: 'https://www.pueblobonito.com/resorts/pacifica/faq' },
|
{ label: 'Quivira FAQ', url: 'https://www.pueblobonito.com/resorts/pacifica/faq' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
createOption({
|
|
||||||
id: 'hotel-dreams-los-cabos',
|
|
||||||
seedKey: 'hotel-dreams-los-cabos',
|
|
||||||
categoryId: 'hotel',
|
|
||||||
name: 'Dreams Los Cabos Suites Golf Resort & Spa',
|
|
||||||
desc: 'Balanced all-inclusive option with the cleanest Apple and Costco pricing signal from today.',
|
|
||||||
details: ['Apple exact-date quote: $1,757 pp', 'Costco package: $1,447.80 pp', 'All-inclusive'],
|
|
||||||
links: [
|
|
||||||
{ label: 'Hyatt Inclusive', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/dreams/mexico/los-cabos-suites-golf-resort-spa/' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
createOption({
|
|
||||||
id: 'hotel-zoetry-casa-del-mar',
|
|
||||||
seedKey: 'hotel-zoetry-casa-del-mar',
|
|
||||||
categoryId: 'hotel',
|
|
||||||
name: 'Zoetry Casa del Mar',
|
|
||||||
desc: 'Higher-end adults-only pick that sits in the luxe tier without going fully maxed out.',
|
|
||||||
details: ['Apple exact-date quote: $1,944 pp', 'Costco package: $1,717.42 pp', 'Adults-only'],
|
|
||||||
links: [
|
|
||||||
{ label: 'Hyatt', url: 'https://www.hyatt.com/en-US/hotel/mexico/zoetry-casa-del-mar/zocdm' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
createOption({
|
|
||||||
id: 'hotel-hyatt-ziva-los-cabos',
|
|
||||||
seedKey: 'hotel-hyatt-ziva-los-cabos',
|
|
||||||
categoryId: 'hotel',
|
|
||||||
name: 'Hyatt Ziva Los Cabos',
|
|
||||||
desc: 'Family-friendly luxury option that still works for a big group if the trip tilts more polished than rowdy.',
|
|
||||||
details: ['Apple exact-date quote: $2,178 pp', 'Beachfront', 'All-inclusive'],
|
|
||||||
links: [
|
|
||||||
{ label: 'Hyatt', url: 'https://www.hyatt.com/en-US/hotel/mexico/hyatt-ziva-los-cabos/sjdif' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
createOption({
|
|
||||||
id: 'hotel-riu-palace-cabo-san-lucas',
|
|
||||||
seedKey: 'hotel-riu-palace-cabo-san-lucas',
|
|
||||||
categoryId: 'hotel',
|
|
||||||
name: 'Riu Palace Cabo San Lucas',
|
|
||||||
desc: 'Value-forward all-inclusive with a more party-friendly profile than the luxury adults-only resorts.',
|
|
||||||
details: ['Apple exact-date quote: $1,529 pp', 'Party-friendly all-inclusive', 'Value pick'],
|
|
||||||
links: [
|
|
||||||
{ label: 'RIU', url: 'https://www.riu.com/en/hotel/mexico/los-cabos/hotel-riu-palace-cabo-san-lucas/' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
createOption({
|
|
||||||
id: 'hotel-riu-palace-baja-california',
|
|
||||||
seedKey: 'hotel-riu-palace-baja-california',
|
|
||||||
categoryId: 'hotel',
|
|
||||||
name: 'Riu Palace Baja California',
|
|
||||||
desc: 'Adults-only RIU choice with a cleaner energy than Cabo San Lucas while staying in the value band.',
|
|
||||||
details: ['Apple exact-date quote: $1,597 pp', 'Adults-only', 'RIU all-inclusive'],
|
|
||||||
links: [
|
|
||||||
{ label: 'RIU', url: 'https://www.riu.com/en/hotel/mexico/los-cabos/hotel-riu-palace-baja-california/' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
createOption({
|
|
||||||
id: 'hotel-me-cabo-by-melia',
|
|
||||||
seedKey: 'hotel-me-cabo-by-melia',
|
|
||||||
categoryId: 'hotel',
|
|
||||||
name: 'ME Cabo by Meliá',
|
|
||||||
desc: 'Beach-club leaning stay for the group that wants energy and location over quiet luxury.',
|
|
||||||
details: ['Apple exact-date quote: $1,533 pp', 'Beach club energy', 'Medano Beach'],
|
|
||||||
links: [
|
|
||||||
{ label: 'ME Cabo', url: 'https://www.hotelmecabo.com/' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
createOption({
|
|
||||||
id: 'hotel-paradisus-los-cabos',
|
|
||||||
seedKey: 'hotel-paradisus-los-cabos',
|
|
||||||
categoryId: 'hotel',
|
|
||||||
name: 'Paradisus Los Cabos',
|
|
||||||
desc: 'Upscale all-inclusive with strong amenities and a better balance than the ultra-luxe splurge properties.',
|
|
||||||
details: ['Apple exact-date quote: $1,722 pp', 'Spa-forward', 'Adults-friendly luxury'],
|
|
||||||
links: [
|
|
||||||
{ label: 'Paradisus', url: 'https://www.paradisusloscabosresort.com/' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
createOption({
|
|
||||||
id: 'hotel-hard-rock-los-cabos',
|
|
||||||
seedKey: 'hotel-hard-rock-los-cabos',
|
|
||||||
categoryId: 'hotel',
|
|
||||||
name: 'Hard Rock Hotel Los Cabos',
|
|
||||||
desc: 'The loudest splurge option from today’s Apple search, with the highest quoted price on the list.',
|
|
||||||
details: ['Apple exact-date quote: $3,343 pp', 'Premium splurge', 'High-energy all-inclusive'],
|
|
||||||
links: [
|
|
||||||
{ label: 'Hard Rock', url: 'https://hotel.hardrock.com/los-cabos' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
createOption({
|
|
||||||
id: 'hotel-solmar-resort',
|
|
||||||
seedKey: 'hotel-solmar-resort',
|
|
||||||
categoryId: 'hotel',
|
|
||||||
name: 'Solmar Resort',
|
|
||||||
desc: 'Low-cost KAYAK option if the group wants to keep the room line item very lean.',
|
|
||||||
details: ['KAYAK exact-date quote: $185/night', 'Budget-friendly', 'Downtown-adjacent'],
|
|
||||||
links: [
|
|
||||||
{ label: 'Solmar', url: 'https://www.solmar.com/en/hotels/cabo-san-lucas/solmar-resort/' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
createOption({
|
|
||||||
id: 'hotel-tesoro-los-cabos',
|
|
||||||
seedKey: 'hotel-tesoro-los-cabos',
|
|
||||||
categoryId: 'hotel',
|
|
||||||
name: 'Tesoro Los Cabos',
|
|
||||||
desc: 'Marina-side value stay that landed in the middle of the KAYAK result set today.',
|
|
||||||
details: ['KAYAK exact-date quote: $250/night', 'Marina access', 'Low-mid budget'],
|
|
||||||
links: [
|
|
||||||
{ label: 'Tesoro', url: 'https://tesoroloscabos.com/' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
createOption({
|
|
||||||
id: 'hotel-grand-solmar-lands-end',
|
|
||||||
seedKey: 'hotel-grand-solmar-lands-end',
|
|
||||||
categoryId: 'hotel',
|
|
||||||
name: "Grand Solmar Land's End Resort & Spa",
|
|
||||||
desc: 'Luxury Pacific-side resort with a stronger price tag than the value stays but below the ultra-splurge properties.',
|
|
||||||
details: ['KAYAK exact-date quote: $712/night', 'Luxury', 'Pacific-side'],
|
|
||||||
links: [
|
|
||||||
{ label: 'Grand Solmar', url: 'https://grandsolmarresort.solmar.com/' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
createOption({
|
|
||||||
id: 'hotel-comfort-inn-suites-los-cabos',
|
|
||||||
seedKey: 'hotel-comfort-inn-suites-los-cabos',
|
|
||||||
categoryId: 'hotel',
|
|
||||||
name: 'Comfort Inn & Suites Los Cabos',
|
|
||||||
desc: 'Bare-bones KAYAK option if the group wants a practical bed-and-shower stay.',
|
|
||||||
details: ['KAYAK exact-date quote: $129/night', 'Budget stay', 'Practical'],
|
|
||||||
links: [
|
|
||||||
{ label: 'KAYAK', url: 'https://www.kayak.com/Cabo-San-Lucas-Hotels-Comfort-Inn-Suites-Los-Cabos.1071781145.ksp' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
createOption({
|
|
||||||
id: 'hotel-capital-o-hotel-dos-mares',
|
|
||||||
seedKey: 'hotel-capital-o-hotel-dos-mares',
|
|
||||||
categoryId: 'hotel',
|
|
||||||
name: 'Capital O Hotel Dos Mares, Cabo San Lucas',
|
|
||||||
desc: 'Lowest visible KAYAK price of the day, useful only if the group is aggressively minimizing room cost.',
|
|
||||||
details: ['KAYAK exact-date quote: $48/night', 'Lowest visible price', 'Budget'],
|
|
||||||
links: [
|
|
||||||
{ label: 'OYO', url: 'https://www.oyorooms.com/mx/92226/' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
createOption({
|
|
||||||
id: 'hotel-villa-del-palmar-beach-resort-cabo-san-lucas',
|
|
||||||
seedKey: 'hotel-villa-del-palmar-beach-resort-cabo-san-lucas',
|
|
||||||
categoryId: 'hotel',
|
|
||||||
name: 'Villa del Palmar Beach Resort Cabo San Lucas',
|
|
||||||
desc: 'Broad-appeal beach resort with a middle-of-the-road KAYAK room price today.',
|
|
||||||
details: ['KAYAK exact-date quote: $460/night', 'Beach resort', 'Family-friendly'],
|
|
||||||
links: [
|
|
||||||
{ label: 'Villa del Palmar', url: 'https://cabo.villadelpalmar.com/' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
createOption({
|
|
||||||
id: 'hotel-esperanza-auberge-collection',
|
|
||||||
seedKey: 'hotel-esperanza-auberge-collection',
|
|
||||||
categoryId: 'hotel',
|
|
||||||
name: 'Esperanza, Auberge Collection',
|
|
||||||
desc: 'Top-end KAYAK splurge result from today, priced well above the other options in the set.',
|
|
||||||
details: ['KAYAK exact-date quote: $3,243/night', 'Luxury splurge', 'Auberge Collection'],
|
|
||||||
links: [
|
|
||||||
{ label: 'Auberge', url: 'https://aubergeresorts.com/esperanza/' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
createOption({
|
|
||||||
id: 'flight-ont-sjd-kayak-cheapest',
|
|
||||||
seedKey: 'flight-ont-sjd-kayak-cheapest',
|
|
||||||
categoryId: 'flight',
|
|
||||||
name: 'ONT to SJD on KAYAK - Cheapest',
|
|
||||||
desc: 'Lowest exact-date round-trip flight quote from Ontario to San Jose del Cabo for the target trip window.',
|
|
||||||
details: ['$402 RT pp', 'Volaris, 1 stop', 'Best budget-flight anchor'],
|
|
||||||
links: [
|
|
||||||
{ label: 'KAYAK Flight Search', url: 'https://www.kayak.com/flights/ONT-SJD/2027-02-03/2027-02-07?sort=bestflight_a' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
createOption({
|
|
||||||
id: 'flight-ont-sjd-kayak-best',
|
|
||||||
seedKey: 'flight-ont-sjd-kayak-best',
|
|
||||||
categoryId: 'flight',
|
|
||||||
name: 'ONT to SJD on KAYAK - Best',
|
|
||||||
desc: 'Higher-comfort exact-date round-trip flight quote for the target trip window.',
|
|
||||||
details: ['$605 RT pp', 'American, 1 stop', 'Best-value comfort anchor'],
|
|
||||||
links: [
|
|
||||||
{ label: 'KAYAK Flight Search', url: 'https://www.kayak.com/flights/ONT-SJD/2027-02-03/2027-02-07?sort=bestflight_a' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
createOption({
|
createOption({
|
||||||
id: 'golf-palmilla',
|
id: 'golf-palmilla',
|
||||||
seedKey: 'golf-palmilla',
|
seedKey: 'golf-palmilla',
|
||||||
@@ -664,7 +457,7 @@ function buildSeedData() {
|
|||||||
lng: -109.7067,
|
lng: -109.7067,
|
||||||
details: ['8 guys: about $1,688 pp', '10 guys: about $1,681 pp', '12 guys: about $1,677 pp'],
|
details: ['8 guys: about $1,688 pp', '10 guys: about $1,681 pp', '12 guys: about $1,677 pp'],
|
||||||
links: [
|
links: [
|
||||||
{ label: 'Grand Fiesta', url: 'https://www.fiestamericanatravelty.com/en/grand-fiesta-americana/hotels/grand-fiesta-americana-los-cabos-all-inclusive-golf-and-spa' },
|
{ label: 'Grand Fiesta', url: 'https://www.fiestamericana.com/en/hotels/grand-fiesta-americana-los-cabos' },
|
||||||
{ label: 'Costco Package', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSGRANDFIESTA20171011' },
|
{ label: 'Costco Package', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSGRANDFIESTA20171011' },
|
||||||
{ label: 'Sailing', url: 'https://www.cabovillas.com/water-tours/cabo-sailing' },
|
{ label: 'Sailing', url: 'https://www.cabovillas.com/water-tours/cabo-sailing' },
|
||||||
],
|
],
|
||||||
@@ -719,7 +512,7 @@ function buildSeedData() {
|
|||||||
desc: 'Grand Fiesta all-inclusive + better golf + sunset sail + one nightlife push. Strongest overall bachelor-weekend value.',
|
desc: 'Grand Fiesta all-inclusive + better golf + sunset sail + one nightlife push. Strongest overall bachelor-weekend value.',
|
||||||
details: ['8: $1,688 pp', '10: $1,681 pp', '12: $1,677 pp'],
|
details: ['8: $1,688 pp', '10: $1,681 pp', '12: $1,677 pp'],
|
||||||
links: [
|
links: [
|
||||||
{ label: 'Grand Fiesta', url: 'https://www.fiestamericanatravelty.com/en/grand-fiesta-americana/hotels/grand-fiesta-americana-los-cabos-all-inclusive-golf-and-spa' },
|
{ label: 'Grand Fiesta', url: 'https://www.fiestamericana.com/en/hotels/grand-fiesta-americana-los-cabos' },
|
||||||
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSGRANDFIESTA20171011' },
|
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSGRANDFIESTA20171011' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
@@ -778,7 +571,6 @@ function mergeSeedData(existing = {}) {
|
|||||||
priceUpdatedAt: seed.priceUpdatedAt,
|
priceUpdatedAt: seed.priceUpdatedAt,
|
||||||
categories: [...seed.categories, ...preservedCustomCategories],
|
categories: [...seed.categories, ...preservedCustomCategories],
|
||||||
budgetScenarios: seed.budgetScenarios,
|
budgetScenarios: seed.budgetScenarios,
|
||||||
guestRoster: seed.guestRoster,
|
|
||||||
options: [...mergedSeedOptions, ...preservedCustomOptions],
|
options: [...mergedSeedOptions, ...preservedCustomOptions],
|
||||||
voters: Array.isArray(existing.voters) ? existing.voters : [],
|
voters: Array.isArray(existing.voters) ? existing.voters : [],
|
||||||
pollsOpen: typeof existing.pollsOpen === 'boolean' ? existing.pollsOpen : true,
|
pollsOpen: typeof existing.pollsOpen === 'boolean' ? existing.pollsOpen : true,
|
||||||
|
|||||||
817
server.js
817
server.js
@@ -1,7 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { WebSocketServer } = require('ws');
|
const { WebSocketServer } = require('ws');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const crypto = require('crypto');
|
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
@@ -12,19 +11,8 @@ const app = express();
|
|||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
const wss = new WebSocketServer({ server });
|
const wss = new WebSocketServer({ server });
|
||||||
|
|
||||||
const DEFAULT_DATA_DIR = path.join(__dirname, 'data');
|
const DATA_DIR = path.join(__dirname, 'data');
|
||||||
const DATA_DIR = process.env.DATA_DIR
|
const DATA_FILE = path.join(DATA_DIR, 'votes.json');
|
||||||
? path.resolve(process.env.DATA_DIR)
|
|
||||||
: DEFAULT_DATA_DIR;
|
|
||||||
const DATA_FILE = process.env.DATA_FILE
|
|
||||||
? path.resolve(process.env.DATA_FILE)
|
|
||||||
: path.join(DATA_DIR, 'votes.json');
|
|
||||||
const DEFAULT_PRICE_HISTORY_FILE = path.join(__dirname, 'price-watch', 'history.jsonl');
|
|
||||||
const PRICE_HISTORY_FILE = process.env.PRICE_HISTORY_FILE
|
|
||||||
? path.resolve(process.env.PRICE_HISTORY_FILE)
|
|
||||||
: DEFAULT_PRICE_HISTORY_FILE;
|
|
||||||
const TRIP_CHECK_IN = process.env.TRIP_CHECK_IN || '2027-02-03';
|
|
||||||
const TRIP_CHECK_OUT = process.env.TRIP_CHECK_OUT || '2027-02-07';
|
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@@ -58,613 +46,6 @@ function saveData(nextData) {
|
|||||||
fs.writeFileSync(DATA_FILE, JSON.stringify(nextData, null, 2));
|
fs.writeFileSync(DATA_FILE, JSON.stringify(nextData, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeKey(value) {
|
|
||||||
return String(value || '')
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
|
||||||
.replace(/^-+|-+$/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeGuestPin(value) {
|
|
||||||
return String(value || '').replace(/\D/g, '').slice(-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeGuestName(value) {
|
|
||||||
return normalizeKey(value).replace(/-/g, ' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSourceLabel(value) {
|
|
||||||
return String(value || 'Unknown source').trim() || 'Unknown source';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCurrencyValue(value, currency = 'USD') {
|
|
||||||
if (typeof value !== 'number' || !Number.isFinite(value)) return '';
|
|
||||||
return new Intl.NumberFormat('en-US', {
|
|
||||||
style: 'currency',
|
|
||||||
currency,
|
|
||||||
maximumFractionDigits: value >= 100 ? 0 : 2,
|
|
||||||
}).format(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeBookingType(value) {
|
|
||||||
const normalized = normalizeKey(value || '');
|
|
||||||
if (['package', 'standalone', 'calculated'].includes(normalized)) return normalized;
|
|
||||||
if (normalized.includes('package') || normalized.includes('bundle')) return 'package';
|
|
||||||
if (normalized.includes('calculated') || normalized.includes('derived')) return 'calculated';
|
|
||||||
return 'standalone';
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferBookingType(point, defaults = {}) {
|
|
||||||
if (point.bookingType || point.booking_type || point.productType || defaults.bookingType) {
|
|
||||||
return point.bookingType || point.booking_type || point.productType || defaults.bookingType;
|
|
||||||
}
|
|
||||||
|
|
||||||
const haystack = [
|
|
||||||
point.source,
|
|
||||||
point.sourceLabel,
|
|
||||||
point.vendor,
|
|
||||||
point.displayPrice,
|
|
||||||
point.displayLabel,
|
|
||||||
point.priceLabel,
|
|
||||||
point.label,
|
|
||||||
].filter(Boolean).join(' ').toLowerCase();
|
|
||||||
|
|
||||||
if (haystack.includes('costco') || haystack.includes('apple vacation') || haystack.includes('cheapcaribbean')) {
|
|
||||||
return 'package';
|
|
||||||
}
|
|
||||||
if (haystack.includes('package') || haystack.includes('flight+hotel') || haystack.includes('flight + hotel')) {
|
|
||||||
return 'package';
|
|
||||||
}
|
|
||||||
if (haystack.includes('automation calculation')) return 'calculated';
|
|
||||||
|
|
||||||
return 'standalone';
|
|
||||||
}
|
|
||||||
|
|
||||||
const GUEST_AUTH_SECRET = process.env.GUEST_AUTH_SECRET || 'cabo-bachelor-party-guest-auth';
|
|
||||||
|
|
||||||
function getGuestRoster() {
|
|
||||||
return Array.isArray(data.guestRoster) ? data.guestRoster : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function findGuestByNameAndPin(name, pin) {
|
|
||||||
const normalizedName = normalizeGuestName(name);
|
|
||||||
const normalizedPin = normalizeGuestPin(pin);
|
|
||||||
|
|
||||||
return getGuestRoster().find((guest) => (
|
|
||||||
normalizeGuestName(guest.name) === normalizedName
|
|
||||||
&& normalizeGuestPin(guest.last4) === normalizedPin
|
|
||||||
)) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function signGuestToken(guest) {
|
|
||||||
const payload = {
|
|
||||||
name: guest.name,
|
|
||||||
last4: normalizeGuestPin(guest.last4),
|
|
||||||
role: guest.role || 'guest',
|
|
||||||
issuedAt: Date.now(),
|
|
||||||
};
|
|
||||||
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
||||||
const signature = crypto
|
|
||||||
.createHmac('sha256', GUEST_AUTH_SECRET)
|
|
||||||
.update(encodedPayload)
|
|
||||||
.digest('base64url');
|
|
||||||
|
|
||||||
return `${encodedPayload}.${signature}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function verifyGuestToken(token) {
|
|
||||||
if (typeof token !== 'string' || !token.includes('.')) return null;
|
|
||||||
|
|
||||||
const [encodedPayload, signature] = token.split('.');
|
|
||||||
if (!encodedPayload || !signature) return null;
|
|
||||||
|
|
||||||
const expectedSignature = crypto
|
|
||||||
.createHmac('sha256', GUEST_AUTH_SECRET)
|
|
||||||
.update(encodedPayload)
|
|
||||||
.digest('base64url');
|
|
||||||
|
|
||||||
const sigA = Buffer.from(signature);
|
|
||||||
const sigB = Buffer.from(expectedSignature);
|
|
||||||
if (sigA.length !== sigB.length || !crypto.timingSafeEqual(sigA, sigB)) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8'));
|
|
||||||
const guest = findGuestByNameAndPin(payload.name, payload.last4);
|
|
||||||
if (!guest) return null;
|
|
||||||
return {
|
|
||||||
name: guest.name,
|
|
||||||
last4: normalizeGuestPin(guest.last4),
|
|
||||||
role: guest.role || 'guest',
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function readGuestAuthToken(req, body = {}) {
|
|
||||||
const bodyToken = body.authToken || body.guestToken || body.token;
|
|
||||||
if (typeof bodyToken === 'string' && bodyToken.trim()) return bodyToken.trim();
|
|
||||||
|
|
||||||
const headerToken = req.headers['x-guest-auth'];
|
|
||||||
if (typeof headerToken === 'string' && headerToken.trim()) return headerToken.trim();
|
|
||||||
|
|
||||||
const authorization = req.headers.authorization || '';
|
|
||||||
if (authorization.toLowerCase().startsWith('bearer ')) {
|
|
||||||
return authorization.slice(7).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireGuestAuth(req, res, body = {}) {
|
|
||||||
const guest = verifyGuestToken(readGuestAuthToken(req, body));
|
|
||||||
if (!guest) {
|
|
||||||
res.status(401).json({ error: 'Guest authentication required' });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return guest;
|
|
||||||
}
|
|
||||||
|
|
||||||
const HISTORY_KEY_ALIASES = {
|
|
||||||
'costco-breathless': 'hotel-breathless',
|
|
||||||
'costco-grand-fiesta': 'hotel-grand-fiesta',
|
|
||||||
'costco-secrets': 'hotel-secrets',
|
|
||||||
'costco-corazon': 'hotel-corazon',
|
|
||||||
'costco-pacifica': 'hotel-pacifica',
|
|
||||||
'costco-dreams': 'hotel-dreams-los-cabos',
|
|
||||||
'costco-zoetry': 'hotel-zoetry-casa-del-mar',
|
|
||||||
'costco-hard-rock': 'hotel-hard-rock-los-cabos',
|
|
||||||
};
|
|
||||||
|
|
||||||
function toTextList(value) {
|
|
||||||
const items = Array.isArray(value) ? value : value == null ? [] : [value];
|
|
||||||
|
|
||||||
return [...new Set(items.flatMap((item) => {
|
|
||||||
if (Array.isArray(item)) {
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item && typeof item === 'object') {
|
|
||||||
return [
|
|
||||||
item.label,
|
|
||||||
item.name,
|
|
||||||
item.text,
|
|
||||||
item.title,
|
|
||||||
item.value,
|
|
||||||
item.summary,
|
|
||||||
item.description,
|
|
||||||
].filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [item];
|
|
||||||
})
|
|
||||||
.map((item) => String(item).trim())
|
|
||||||
.filter(Boolean))];
|
|
||||||
}
|
|
||||||
|
|
||||||
function readJsonLines(filePath) {
|
|
||||||
if (!fs.existsSync(filePath)) return [];
|
|
||||||
|
|
||||||
return fs.readFileSync(filePath, 'utf8')
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((line) => {
|
|
||||||
try {
|
|
||||||
return JSON.parse(line);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function latestNonEmptyArray(runs, fieldNames) {
|
|
||||||
for (let index = runs.length - 1; index >= 0; index -= 1) {
|
|
||||||
for (const fieldName of fieldNames) {
|
|
||||||
if (Array.isArray(runs[index][fieldName]) && runs[index][fieldName].length) {
|
|
||||||
return runs[index][fieldName];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOptionHistoryKeys(option) {
|
|
||||||
const nameKey = normalizeKey(option.name);
|
|
||||||
const categoryNameKey = option.categoryId && nameKey ? `${option.categoryId}-${nameKey}` : '';
|
|
||||||
const rawKeys = [
|
|
||||||
option.seedKey,
|
|
||||||
option.id,
|
|
||||||
option.priceKey,
|
|
||||||
option.optionKey,
|
|
||||||
option.slug,
|
|
||||||
categoryNameKey,
|
|
||||||
nameKey,
|
|
||||||
].filter(Boolean).map(normalizeKey);
|
|
||||||
|
|
||||||
return [...new Set(rawKeys.flatMap((key) => [key, HISTORY_KEY_ALIASES[key] || null].filter(Boolean)))];
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractNumericPrice(point) {
|
|
||||||
const candidates = [
|
|
||||||
point.price,
|
|
||||||
point.value,
|
|
||||||
point.amount,
|
|
||||||
point.perPerson,
|
|
||||||
point.groupTotal,
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (typeof candidate === 'number' && Number.isFinite(candidate)) return candidate;
|
|
||||||
if (typeof candidate === 'string') {
|
|
||||||
const parsed = parseNumericPriceFromText(candidate);
|
|
||||||
if (Number.isFinite(parsed)) return parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const textCandidates = [
|
|
||||||
point.displayPrice,
|
|
||||||
point.displayLabel,
|
|
||||||
point.priceLabel,
|
|
||||||
point.label,
|
|
||||||
point.note,
|
|
||||||
point.description,
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
for (const candidate of textCandidates) {
|
|
||||||
const parsed = parseNumericPriceFromText(candidate, point.priceBasis || point.price_basis || point.unit);
|
|
||||||
if (Number.isFinite(parsed)) return parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseNumericPriceFromText(value, priceBasis = '') {
|
|
||||||
if (typeof value !== 'string') return null;
|
|
||||||
const normalized = value.replace(/\s+/g, ' ').trim();
|
|
||||||
if (!normalized || /\b(no|not)\s+(fresh\s+)?(price|rates?|available|counted|visible|captured)\b/i.test(normalized)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const matches = [...normalized.matchAll(/\$?\s*([0-9]{1,3}(?:,[0-9]{3})*(?:\.[0-9]{1,2})?|[0-9]+(?:\.[0-9]{1,2})?)/g)]
|
|
||||||
.map((match) => ({
|
|
||||||
value: Number(match[1].replace(/,/g, '')),
|
|
||||||
index: match.index || 0,
|
|
||||||
}))
|
|
||||||
.filter((match) => Number.isFinite(match.value));
|
|
||||||
|
|
||||||
if (!matches.length) return null;
|
|
||||||
|
|
||||||
const basis = normalizeKey(priceBasis);
|
|
||||||
if (basis === 'totalpackage' || basis === 'pergroup') {
|
|
||||||
return matches.at(-1).value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const travelerMatch = matches.find((match) => (
|
|
||||||
/per\s+(traveler|person|guest|adult|round|table|night)|pp|\/night/i.test(normalized.slice(match.index, match.index + 80))
|
|
||||||
));
|
|
||||||
if (travelerMatch) return travelerMatch.value;
|
|
||||||
|
|
||||||
return matches[0].value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferPriceBasis(point, defaults = {}) {
|
|
||||||
if (point.priceBasis || point.price_basis || point.unit || defaults.priceBasis) {
|
|
||||||
return point.priceBasis || point.price_basis || point.unit || defaults.priceBasis;
|
|
||||||
}
|
|
||||||
|
|
||||||
const haystack = [
|
|
||||||
point.displayPrice,
|
|
||||||
point.displayLabel,
|
|
||||||
point.priceLabel,
|
|
||||||
point.label,
|
|
||||||
point.note,
|
|
||||||
point.description,
|
|
||||||
].filter(Boolean).join(' ').toLowerCase();
|
|
||||||
|
|
||||||
if (haystack.includes('/night') || haystack.includes('per night')) return 'perNight';
|
|
||||||
if (haystack.includes('per traveler')) return 'perTraveler';
|
|
||||||
if (haystack.includes('per person') || /\bpp\b/.test(haystack)) return 'perPerson';
|
|
||||||
if (haystack.includes('per round')) return 'perRound';
|
|
||||||
if (haystack.includes('per table')) return 'perTable';
|
|
||||||
if (haystack.includes('total') || haystack.includes('package')) return 'totalPackage';
|
|
||||||
|
|
||||||
const bookingType = normalizeBookingType(inferBookingType(point, defaults));
|
|
||||||
if (bookingType === 'package') return 'perTraveler';
|
|
||||||
if (bookingType === 'calculated') return 'perPerson';
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTripNightCount() {
|
|
||||||
const checkInMs = Date.parse(`${TRIP_CHECK_IN}T00:00:00Z`);
|
|
||||||
const checkOutMs = Date.parse(`${TRIP_CHECK_OUT}T00:00:00Z`);
|
|
||||||
if (Number.isNaN(checkInMs) || Number.isNaN(checkOutMs) || checkOutMs <= checkInMs) return 1;
|
|
||||||
return Math.max(1, Math.round((checkOutMs - checkInMs) / (24 * 60 * 60 * 1000)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isStayUnitPrice(priceBasis) {
|
|
||||||
const normalized = normalizeKey(priceBasis);
|
|
||||||
return ['pernight', 'perday', 'nightly', 'daily'].includes(normalized);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTripPrice({ price, priceBasis, currency, displayPrice }) {
|
|
||||||
if (!isStayUnitPrice(priceBasis)) {
|
|
||||||
return {
|
|
||||||
price,
|
|
||||||
unitPrice: price,
|
|
||||||
tripTotalPrice: price,
|
|
||||||
displayPrice,
|
|
||||||
tripNights: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const tripNights = getTripNightCount();
|
|
||||||
const tripTotalPrice = Number((price * tripNights).toFixed(2));
|
|
||||||
const unitLabel = displayPrice || `${formatCurrencyValue(price, currency)}/night`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
price: tripTotalPrice,
|
|
||||||
unitPrice: price,
|
|
||||||
tripTotalPrice,
|
|
||||||
displayPrice: `${formatCurrencyValue(tripTotalPrice, currency)} stay total (${unitLabel} x ${tripNights} nights)`,
|
|
||||||
tripNights,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadPriceHistoryState() {
|
|
||||||
const runs = readJsonLines(PRICE_HISTORY_FILE)
|
|
||||||
.map((entry, index) => {
|
|
||||||
const checkedAtRaw = entry.checkedAt || entry.checked_at || entry.runAt || entry.timestamp || entry.date || null;
|
|
||||||
const checkedAtMs = checkedAtRaw ? Date.parse(checkedAtRaw) : Date.now() + index;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...entry,
|
|
||||||
checkedAt: Number.isNaN(checkedAtMs) ? null : new Date(checkedAtMs).toISOString(),
|
|
||||||
checkedAtMs: Number.isNaN(checkedAtMs) ? Date.now() + index : checkedAtMs,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.sort((a, b) => a.checkedAtMs - b.checkedAtMs);
|
|
||||||
|
|
||||||
runs.forEach((run, index) => {
|
|
||||||
run.runIndex = index;
|
|
||||||
});
|
|
||||||
|
|
||||||
const seriesByKey = new Map();
|
|
||||||
|
|
||||||
const addPointToSeries = (run, point, defaults = {}) => {
|
|
||||||
const key = normalizeKey(
|
|
||||||
point.optionKey || point.optionId || point.seedKey || point.slug || point.key || point.name,
|
|
||||||
);
|
|
||||||
const price = extractNumericPrice({
|
|
||||||
...defaults,
|
|
||||||
...point,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!key || price === null) return;
|
|
||||||
const currency = point.currency || defaults.currency || 'USD';
|
|
||||||
const priceBasis = inferPriceBasis(point, defaults);
|
|
||||||
const rawDisplayPrice = point.displayPrice || point.priceLabel || point.displayLabel || point.label || defaults.displayPrice || null;
|
|
||||||
const tripPrice = normalizeTripPrice({
|
|
||||||
price,
|
|
||||||
priceBasis,
|
|
||||||
currency,
|
|
||||||
displayPrice: rawDisplayPrice,
|
|
||||||
});
|
|
||||||
|
|
||||||
const nextPoint = {
|
|
||||||
checkedAt: run.checkedAt,
|
|
||||||
checkedAtMs: run.checkedAtMs,
|
|
||||||
runIndex: run.runIndex,
|
|
||||||
price: tripPrice.price,
|
|
||||||
unitPrice: tripPrice.unitPrice,
|
|
||||||
tripTotalPrice: tripPrice.tripTotalPrice,
|
|
||||||
tripNights: tripPrice.tripNights,
|
|
||||||
tripCheckIn: tripPrice.tripNights ? TRIP_CHECK_IN : null,
|
|
||||||
tripCheckOut: tripPrice.tripNights ? TRIP_CHECK_OUT : null,
|
|
||||||
currency,
|
|
||||||
displayPrice: tripPrice.displayPrice,
|
|
||||||
unitDisplayPrice: rawDisplayPrice,
|
|
||||||
source: normalizeSourceLabel(point.source || point.sourceLabel || point.vendor || defaults.source || null),
|
|
||||||
sourceKey: normalizeKey(point.sourceKey || point.sourceId || point.source || point.sourceLabel || point.vendor || defaults.sourceKey || 'unknown-source'),
|
|
||||||
sourceUrl: point.sourceUrl || point.url || defaults.sourceUrl || null,
|
|
||||||
bookingType: normalizeBookingType(inferBookingType(point, defaults)),
|
|
||||||
priceBasis,
|
|
||||||
includedComponents: toTextList(point.includedComponents || point.includesComponents || point.componentsIncluded || defaults.includedComponents),
|
|
||||||
excludedComponents: toTextList(point.excludedComponents || point.componentsExcluded || defaults.excludedComponents),
|
|
||||||
origin: point.origin || point.originAirport || defaults.origin || null,
|
|
||||||
destination: point.destination || point.destinationAirport || defaults.destination || null,
|
|
||||||
note: point.note || point.description || null,
|
|
||||||
availability: point.availability || point.status || null,
|
|
||||||
decisionNote: point.decisionNote || point.note || point.description || defaults.decisionNote || null,
|
|
||||||
highlights: toTextList(point.highlights || point.summaryBullets || point.bullets || defaults.highlights),
|
|
||||||
features: toTextList(point.features || point.featureHighlights || point.featureLabels || defaults.features),
|
|
||||||
amenities: toTextList(point.amenities || point.amenityHighlights || point.amenityLabels || defaults.amenities),
|
|
||||||
inclusions: toTextList(point.inclusions || point.includes || point.perks || defaults.inclusions),
|
|
||||||
limitations: toTextList(point.limitations || point.tradeoffs || point.caveats || defaults.limitations),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!seriesByKey.has(key)) seriesByKey.set(key, []);
|
|
||||||
seriesByKey.get(key).push(nextPoint);
|
|
||||||
};
|
|
||||||
|
|
||||||
runs.forEach((run) => {
|
|
||||||
const pricePoints = Array.isArray(run.optionPrices)
|
|
||||||
? run.optionPrices
|
|
||||||
: Array.isArray(run.prices)
|
|
||||||
? run.prices
|
|
||||||
: Array.isArray(run.trackedPrices)
|
|
||||||
? run.trackedPrices
|
|
||||||
: [];
|
|
||||||
|
|
||||||
pricePoints.forEach((point) => {
|
|
||||||
addPointToSeries(run, point);
|
|
||||||
});
|
|
||||||
|
|
||||||
const derivedItineraries = Array.isArray(run.derivedItineraries)
|
|
||||||
? run.derivedItineraries
|
|
||||||
: Array.isArray(run.itineraryScenarios)
|
|
||||||
? run.itineraryScenarios
|
|
||||||
: [];
|
|
||||||
|
|
||||||
derivedItineraries.forEach((itinerary) => {
|
|
||||||
addPointToSeries(run, itinerary, {
|
|
||||||
bookingType: 'calculated',
|
|
||||||
priceBasis: 'perPerson',
|
|
||||||
source: 'Automation calculation',
|
|
||||||
sourceKey: 'automation-calculation',
|
|
||||||
displayPrice: typeof itinerary.perPerson === 'number' ? `$${itinerary.perPerson.toLocaleString()} pp` : null,
|
|
||||||
price: itinerary.perPerson,
|
|
||||||
decisionNote: itinerary.summary,
|
|
||||||
highlights: itinerary.assumptions,
|
|
||||||
inclusions: itinerary.components,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
seriesByKey.forEach((series) => {
|
|
||||||
series.sort((a, b) => a.runIndex - b.runIndex);
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
runs,
|
|
||||||
seriesByKey,
|
|
||||||
budgetScenarios: latestNonEmptyArray(runs, ['budgetScenarios', 'derivedBudgetScenarios']),
|
|
||||||
derivedItineraries: latestNonEmptyArray(runs, ['derivedItineraries', 'itineraryScenarios']),
|
|
||||||
latestCheckedAt: runs.at(-1)?.checkedAt || null,
|
|
||||||
totalRuns: runs.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPriceHistoryBySource(priceHistory) {
|
|
||||||
const grouped = new Map();
|
|
||||||
|
|
||||||
priceHistory.forEach((point) => {
|
|
||||||
const sourceKey = normalizeKey(point.sourceKey || point.source || 'unknown-source');
|
|
||||||
const sourceLabel = normalizeSourceLabel(point.source || point.sourceLabel || sourceKey);
|
|
||||||
if (!grouped.has(sourceKey)) {
|
|
||||||
grouped.set(sourceKey, {
|
|
||||||
sourceKey,
|
|
||||||
sourceLabel,
|
|
||||||
sourceUrl: point.sourceUrl || null,
|
|
||||||
points: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const bucket = grouped.get(sourceKey);
|
|
||||||
bucket.points.push({
|
|
||||||
...point,
|
|
||||||
sourceKey,
|
|
||||||
source: sourceLabel,
|
|
||||||
});
|
|
||||||
if (!bucket.sourceUrl && point.sourceUrl) bucket.sourceUrl = point.sourceUrl;
|
|
||||||
if (bucket.sourceLabel === 'Unknown source' && sourceLabel) bucket.sourceLabel = sourceLabel;
|
|
||||||
});
|
|
||||||
|
|
||||||
const seriesBySource = {};
|
|
||||||
const sourceSummaries = [...grouped.values()].map((bucket) => {
|
|
||||||
bucket.points.sort((a, b) => a.runIndex - b.runIndex);
|
|
||||||
seriesBySource[bucket.sourceKey] = bucket.points;
|
|
||||||
const latestPoint = bucket.points.at(-1) || null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
sourceKey: bucket.sourceKey,
|
|
||||||
sourceLabel: bucket.sourceLabel,
|
|
||||||
sourceUrl: bucket.sourceUrl,
|
|
||||||
bookingType: latestPoint?.bookingType || null,
|
|
||||||
priceBasis: latestPoint?.priceBasis || null,
|
|
||||||
pointCount: bucket.points.length,
|
|
||||||
latestCheckedAt: latestPoint?.checkedAt || null,
|
|
||||||
latestCheckedAtMs: latestPoint?.checkedAtMs || 0,
|
|
||||||
latestPrice: latestPoint?.price ?? null,
|
|
||||||
latestDisplayPrice: latestPoint?.displayPrice || null,
|
|
||||||
currency: latestPoint?.currency || 'USD',
|
|
||||||
};
|
|
||||||
}).sort((a, b) => {
|
|
||||||
const aMs = a.latestCheckedAtMs || 0;
|
|
||||||
const bMs = b.latestCheckedAtMs || 0;
|
|
||||||
if (aMs !== bMs) return bMs - aMs;
|
|
||||||
return a.sourceLabel.localeCompare(b.sourceLabel);
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
seriesBySource,
|
|
||||||
sourceSummaries,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPriceHistoryForOption(option, priceHistoryState) {
|
|
||||||
const optionKeys = getOptionHistoryKeys(option);
|
|
||||||
for (const key of optionKeys) {
|
|
||||||
const series = priceHistoryState.seriesByKey.get(key);
|
|
||||||
if (series && series.length) {
|
|
||||||
return series;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function decorateOptionWithPriceHistory(option, priceHistoryState) {
|
|
||||||
const priceHistory = getPriceHistoryForOption(option, priceHistoryState);
|
|
||||||
const { seriesBySource, sourceSummaries } = buildPriceHistoryBySource(priceHistory);
|
|
||||||
const defaultSourceSummary = sourceSummaries[0] || null;
|
|
||||||
const defaultSourceKey = defaultSourceSummary?.sourceKey || null;
|
|
||||||
const defaultPriceHistory = defaultSourceKey ? seriesBySource[defaultSourceKey] || [] : priceHistory;
|
|
||||||
const latestPricePoint = defaultPriceHistory.at(-1) || null;
|
|
||||||
const optionDetails = toTextList(option.details);
|
|
||||||
const automationHighlights = toTextList(latestPricePoint?.highlights);
|
|
||||||
const automationFeatures = toTextList(latestPricePoint?.features);
|
|
||||||
const automationAmenities = toTextList(latestPricePoint?.amenities);
|
|
||||||
const automationInclusions = toTextList(latestPricePoint?.inclusions);
|
|
||||||
const automationLimitations = toTextList(latestPricePoint?.limitations);
|
|
||||||
const decisionDetails = [
|
|
||||||
...optionDetails,
|
|
||||||
...automationHighlights,
|
|
||||||
...automationFeatures,
|
|
||||||
...automationAmenities,
|
|
||||||
...automationInclusions,
|
|
||||||
...automationLimitations,
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
...option,
|
|
||||||
priceHistory: defaultPriceHistory,
|
|
||||||
priceHistoryBySource: seriesBySource,
|
|
||||||
availableSources: sourceSummaries,
|
|
||||||
defaultSourceKey,
|
|
||||||
currentSourceKey: defaultSourceKey,
|
|
||||||
latestPricePoint,
|
|
||||||
currentPrice: latestPricePoint?.price ?? null,
|
|
||||||
decisionDetails: [...new Set(decisionDetails)],
|
|
||||||
automationInsights: latestPricePoint ? {
|
|
||||||
currentPrice: latestPricePoint.price,
|
|
||||||
currency: latestPricePoint.currency || 'USD',
|
|
||||||
displayPrice: latestPricePoint.displayPrice || null,
|
|
||||||
source: latestPricePoint.source || null,
|
|
||||||
sourceUrl: latestPricePoint.sourceUrl || null,
|
|
||||||
bookingType: latestPricePoint.bookingType || null,
|
|
||||||
priceBasis: latestPricePoint.priceBasis || null,
|
|
||||||
includedComponents: latestPricePoint.includedComponents || [],
|
|
||||||
excludedComponents: latestPricePoint.excludedComponents || [],
|
|
||||||
availability: latestPricePoint.availability || null,
|
|
||||||
decisionNote: latestPricePoint.decisionNote || null,
|
|
||||||
highlights: automationHighlights,
|
|
||||||
features: automationFeatures,
|
|
||||||
amenities: automationAmenities,
|
|
||||||
inclusions: automationInclusions,
|
|
||||||
limitations: automationLimitations,
|
|
||||||
} : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function decorateOptionsWithPriceHistory(options, priceHistoryState) {
|
|
||||||
return options.map((option) => decorateOptionWithPriceHistory(option, priceHistoryState));
|
|
||||||
}
|
|
||||||
|
|
||||||
function approvedOptionsWithVoteSummary() {
|
function approvedOptionsWithVoteSummary() {
|
||||||
return data.options
|
return data.options
|
||||||
.filter((option) => option.approved)
|
.filter((option) => option.approved)
|
||||||
@@ -683,43 +64,19 @@ function broadcast(payload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildRealtimeSnapshot() {
|
function buildRealtimeSnapshot() {
|
||||||
const priceHistoryState = loadPriceHistoryState();
|
|
||||||
const approvedOptions = data.options.filter((option) => option.approved);
|
|
||||||
const budgetScenarios = priceHistoryState.budgetScenarios || data.budgetScenarios || [];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'init',
|
type: 'init',
|
||||||
pollsOpen: data.pollsOpen,
|
pollsOpen: data.pollsOpen,
|
||||||
categories: data.categories,
|
categories: data.categories,
|
||||||
tripCheckIn: TRIP_CHECK_IN,
|
options: data.options.filter((option) => option.approved),
|
||||||
tripCheckOut: TRIP_CHECK_OUT,
|
|
||||||
guestRoster: getGuestRoster().map((guest) => ({
|
|
||||||
name: guest.name,
|
|
||||||
role: guest.role || 'guest',
|
|
||||||
})),
|
|
||||||
options: decorateOptionsWithPriceHistory(approvedOptions, priceHistoryState),
|
|
||||||
results: approvedOptionsWithVoteSummary(),
|
results: approvedOptionsWithVoteSummary(),
|
||||||
totalVoters: data.voters.length,
|
totalVoters: data.voters.length,
|
||||||
budgetScenarios,
|
budgetScenarios: data.budgetScenarios || [],
|
||||||
priceUpdatedAt: priceHistoryState.latestCheckedAt || data.priceUpdatedAt || null,
|
priceUpdatedAt: data.priceUpdatedAt || null,
|
||||||
priceHistoryRunCount: priceHistoryState.totalRuns,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDetails(details) {
|
function createUserOption({ categoryId, name, desc, url, voterName, lat, lng, approved }) {
|
||||||
if (Array.isArray(details)) {
|
|
||||||
return details.map((item) => String(item || '').trim()).filter(Boolean);
|
|
||||||
}
|
|
||||||
if (typeof details === 'string') {
|
|
||||||
return details
|
|
||||||
.split(/\n+/)
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function createUserOption({ categoryId, name, desc, url, voterName, lat, lng, approved, details }) {
|
|
||||||
return {
|
return {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
seedKey: null,
|
seedKey: null,
|
||||||
@@ -733,52 +90,13 @@ function createUserOption({ categoryId, name, desc, url, voterName, lat, lng, ap
|
|||||||
addedBy: voterName,
|
addedBy: voterName,
|
||||||
approved,
|
approved,
|
||||||
votes: [],
|
votes: [],
|
||||||
details: normalizeDetails(details),
|
details: [],
|
||||||
categoryColor: CATEGORY_META[categoryId]?.color || '#888',
|
categoryColor: CATEGORY_META[categoryId]?.color || '#888',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = loadData();
|
let data = loadData();
|
||||||
|
|
||||||
app.get('/api/auth/guests', (req, res) => {
|
|
||||||
res.json({
|
|
||||||
guests: getGuestRoster().map((guest) => ({
|
|
||||||
name: guest.name,
|
|
||||||
role: guest.role || 'guest',
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/auth/me', (req, res) => {
|
|
||||||
const guest = verifyGuestToken(readGuestAuthToken(req));
|
|
||||||
if (!guest) {
|
|
||||||
return res.status(401).json({ authenticated: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
authenticated: true,
|
|
||||||
guest,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/api/auth/login', (req, res) => {
|
|
||||||
const { name, pin } = req.body || {};
|
|
||||||
const guest = findGuestByNameAndPin(name, pin);
|
|
||||||
|
|
||||||
if (!guest) {
|
|
||||||
return res.status(401).json({ error: 'Invalid guest name or code' });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
token: signGuestToken(guest),
|
|
||||||
guest: {
|
|
||||||
name: guest.name,
|
|
||||||
role: guest.role || 'guest',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/categories', (req, res) => {
|
app.get('/api/categories', (req, res) => {
|
||||||
res.json(data.categories);
|
res.json(data.categories);
|
||||||
});
|
});
|
||||||
@@ -786,69 +104,43 @@ app.get('/api/categories', (req, res) => {
|
|||||||
app.get('/api/options', (req, res) => {
|
app.get('/api/options', (req, res) => {
|
||||||
const { category, includeUnapproved } = req.query;
|
const { category, includeUnapproved } = req.query;
|
||||||
let options = data.options;
|
let options = data.options;
|
||||||
const priceHistoryState = loadPriceHistoryState();
|
|
||||||
|
|
||||||
if (category) options = options.filter((option) => option.categoryId === category);
|
if (category) options = options.filter((option) => option.categoryId === category);
|
||||||
if (!includeUnapproved) options = options.filter((option) => option.approved);
|
if (!includeUnapproved) options = options.filter((option) => option.approved);
|
||||||
|
|
||||||
res.json(decorateOptionsWithPriceHistory(options, priceHistoryState));
|
res.json(options);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/results', (req, res) => {
|
app.get('/api/results', (req, res) => {
|
||||||
const priceHistoryState = loadPriceHistoryState();
|
|
||||||
const budgetScenarios = priceHistoryState.budgetScenarios || data.budgetScenarios || [];
|
|
||||||
const results = data.categories.map((category) => ({
|
const results = data.categories.map((category) => ({
|
||||||
...category,
|
...category,
|
||||||
options: data.options
|
options: data.options
|
||||||
.filter((option) => option.approved && option.categoryId === category.id)
|
.filter((option) => option.approved && option.categoryId === category.id)
|
||||||
.map((option) => ({
|
.map((option) => ({ ...option, voteCount: option.votes.length })),
|
||||||
...decorateOptionWithPriceHistory(option, priceHistoryState),
|
|
||||||
voteCount: option.votes.length,
|
|
||||||
})),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
pollsOpen: data.pollsOpen,
|
pollsOpen: data.pollsOpen,
|
||||||
results,
|
results,
|
||||||
totalVoters: data.voters.length,
|
totalVoters: data.voters.length,
|
||||||
budgetScenarios,
|
budgetScenarios: data.budgetScenarios || [],
|
||||||
priceUpdatedAt: priceHistoryState.latestCheckedAt || data.priceUpdatedAt || null,
|
priceUpdatedAt: data.priceUpdatedAt || null,
|
||||||
priceHistoryRunCount: priceHistoryState.totalRuns,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/budgets', (req, res) => {
|
app.get('/api/budgets', (req, res) => {
|
||||||
const priceHistoryState = loadPriceHistoryState();
|
|
||||||
res.json({
|
res.json({
|
||||||
updatedAt: priceHistoryState.latestCheckedAt || data.priceUpdatedAt || null,
|
updatedAt: data.priceUpdatedAt || null,
|
||||||
scenarios: priceHistoryState.budgetScenarios || data.budgetScenarios || [],
|
scenarios: data.budgetScenarios || [],
|
||||||
derivedItineraries: priceHistoryState.derivedItineraries || [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/price-history', (req, res) => {
|
|
||||||
const priceHistoryState = loadPriceHistoryState();
|
|
||||||
const seriesByOption = Object.fromEntries(
|
|
||||||
[...priceHistoryState.seriesByKey.entries()],
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
latestCheckedAt: priceHistoryState.latestCheckedAt,
|
|
||||||
totalRuns: priceHistoryState.totalRuns,
|
|
||||||
seriesByOption,
|
|
||||||
budgetScenarios: priceHistoryState.budgetScenarios || [],
|
|
||||||
derivedItineraries: priceHistoryState.derivedItineraries || [],
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/vote', (req, res) => {
|
app.post('/api/vote', (req, res) => {
|
||||||
const { optionId } = req.body;
|
const { optionId, voterName } = req.body;
|
||||||
const guest = requireGuestAuth(req, res, req.body);
|
|
||||||
|
|
||||||
if (!optionId) {
|
if (!voterName || !optionId) {
|
||||||
return res.status(400).json({ error: 'Missing fields' });
|
return res.status(400).json({ error: 'Missing fields' });
|
||||||
}
|
}
|
||||||
if (!guest) return;
|
|
||||||
if (!data.pollsOpen) {
|
if (!data.pollsOpen) {
|
||||||
return res.status(403).json({ error: 'Polls are closed' });
|
return res.status(403).json({ error: 'Polls are closed' });
|
||||||
}
|
}
|
||||||
@@ -860,17 +152,17 @@ app.post('/api/vote', (req, res) => {
|
|||||||
|
|
||||||
const previousVote = data.options.find((candidate) => (
|
const previousVote = data.options.find((candidate) => (
|
||||||
candidate.categoryId === option.categoryId
|
candidate.categoryId === option.categoryId
|
||||||
&& candidate.votes.some((vote) => vote.name === guest.name)
|
&& candidate.votes.some((vote) => vote.name === voterName)
|
||||||
));
|
));
|
||||||
|
|
||||||
if (previousVote) {
|
if (previousVote) {
|
||||||
previousVote.votes = previousVote.votes.filter((vote) => vote.name !== guest.name);
|
previousVote.votes = previousVote.votes.filter((vote) => vote.name !== voterName);
|
||||||
}
|
}
|
||||||
|
|
||||||
option.votes.push({ name: guest.name, timestamp: Date.now() });
|
option.votes.push({ name: voterName, timestamp: Date.now() });
|
||||||
|
|
||||||
if (!data.voters.find((voter) => voter.name === guest.name)) {
|
if (!data.voters.find((voter) => voter.name === voterName)) {
|
||||||
data.voters.push({ name: guest.name, joinedAt: Date.now() });
|
data.voters.push({ name: voterName, joinedAt: Date.now() });
|
||||||
}
|
}
|
||||||
|
|
||||||
saveData(data);
|
saveData(data);
|
||||||
@@ -879,26 +171,23 @@ app.post('/api/vote', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/vote/:optionId', (req, res) => {
|
app.delete('/api/vote/:optionId', (req, res) => {
|
||||||
const guest = requireGuestAuth(req, res, req.body);
|
const { voterName } = req.body;
|
||||||
if (!guest) return;
|
|
||||||
const option = data.options.find((candidate) => candidate.id === req.params.optionId);
|
const option = data.options.find((candidate) => candidate.id === req.params.optionId);
|
||||||
|
|
||||||
if (!option) return res.status(404).json({ error: 'Not found' });
|
if (!option) return res.status(404).json({ error: 'Not found' });
|
||||||
|
|
||||||
option.votes = option.votes.filter((vote) => vote.name !== guest.name);
|
option.votes = option.votes.filter((vote) => vote.name !== voterName);
|
||||||
saveData(data);
|
saveData(data);
|
||||||
broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() });
|
broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() });
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/options', (req, res) => {
|
app.post('/api/options', (req, res) => {
|
||||||
const { categoryId, name, desc, url, lat, lng, details } = req.body;
|
const { categoryId, name, desc, url, voterName, lat, lng } = req.body;
|
||||||
const guest = requireGuestAuth(req, res, req.body);
|
|
||||||
|
|
||||||
if (!categoryId || !name) {
|
if (!categoryId || !name || !voterName) {
|
||||||
return res.status(400).json({ error: 'Missing required fields' });
|
return res.status(400).json({ error: 'Missing required fields' });
|
||||||
}
|
}
|
||||||
if (!guest) return;
|
|
||||||
|
|
||||||
const category = data.categories.find((candidate) => candidate.id === categoryId);
|
const category = data.categories.find((candidate) => candidate.id === categoryId);
|
||||||
if (!category) return res.status(404).json({ error: 'Category not found' });
|
if (!category) return res.status(404).json({ error: 'Category not found' });
|
||||||
@@ -908,10 +197,9 @@ app.post('/api/options', (req, res) => {
|
|||||||
name,
|
name,
|
||||||
desc,
|
desc,
|
||||||
url,
|
url,
|
||||||
voterName: guest.name,
|
voterName,
|
||||||
lat,
|
lat,
|
||||||
lng,
|
lng,
|
||||||
details,
|
|
||||||
approved: false,
|
approved: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -931,25 +219,6 @@ app.post('/api/options/:id/approve', (req, res) => {
|
|||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.patch('/api/options/:id', (req, res) => {
|
|
||||||
const option = data.options.find((candidate) => candidate.id === req.params.id);
|
|
||||||
if (!option) return res.status(404).json({ error: 'Not found' });
|
|
||||||
|
|
||||||
const { categoryId, name, desc, url, lat, lng, details, approved } = req.body;
|
|
||||||
if (categoryId !== undefined) option.categoryId = categoryId;
|
|
||||||
if (name !== undefined) option.name = String(name || '').trim();
|
|
||||||
if (desc !== undefined) option.desc = String(desc || '').trim();
|
|
||||||
if (url !== undefined) option.url = String(url || '').trim() || null;
|
|
||||||
if (lat !== undefined) option.lat = Number.isFinite(Number(lat)) ? Number(lat) : null;
|
|
||||||
if (lng !== undefined) option.lng = Number.isFinite(Number(lng)) ? Number(lng) : null;
|
|
||||||
if (details !== undefined) option.details = normalizeDetails(details);
|
|
||||||
if (approved !== undefined) option.approved = Boolean(approved);
|
|
||||||
|
|
||||||
saveData(data);
|
|
||||||
broadcast({ type: 'option_updated', option });
|
|
||||||
res.json({ success: true, option });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.delete('/api/options/:id', (req, res) => {
|
app.delete('/api/options/:id', (req, res) => {
|
||||||
const optionIndex = data.options.findIndex((candidate) => candidate.id === req.params.id);
|
const optionIndex = data.options.findIndex((candidate) => candidate.id === req.params.id);
|
||||||
if (optionIndex === -1) return res.status(404).json({ error: 'Not found' });
|
if (optionIndex === -1) return res.status(404).json({ error: 'Not found' });
|
||||||
@@ -1029,12 +298,8 @@ wss.on('connection', (ws) => {
|
|||||||
const msg = JSON.parse(raw);
|
const msg = JSON.parse(raw);
|
||||||
|
|
||||||
if (msg.type === 'vote') {
|
if (msg.type === 'vote') {
|
||||||
const { optionId, remove } = msg;
|
const { optionId, voterName, remove } = msg;
|
||||||
const guest = verifyGuestToken(msg.authToken || msg.guestToken || null);
|
if (!voterName || !optionId) return;
|
||||||
if (!guest || !optionId) {
|
|
||||||
ws.send(JSON.stringify({ type: 'error', message: 'Guest authentication required' }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!data.pollsOpen) {
|
if (!data.pollsOpen) {
|
||||||
ws.send(JSON.stringify({ type: 'error', message: 'Polls closed' }));
|
ws.send(JSON.stringify({ type: 'error', message: 'Polls closed' }));
|
||||||
return;
|
return;
|
||||||
@@ -1044,39 +309,35 @@ wss.on('connection', (ws) => {
|
|||||||
if (!option || !option.approved) return;
|
if (!option || !option.approved) return;
|
||||||
|
|
||||||
if (remove) {
|
if (remove) {
|
||||||
option.votes = option.votes.filter((vote) => vote.name !== guest.name);
|
option.votes = option.votes.filter((vote) => vote.name !== voterName);
|
||||||
} else {
|
} else {
|
||||||
const previousVote = data.options.find((candidate) => (
|
const previousVote = data.options.find((candidate) => (
|
||||||
candidate.categoryId === option.categoryId
|
candidate.categoryId === option.categoryId
|
||||||
&& candidate.votes.some((vote) => vote.name === guest.name)
|
&& candidate.votes.some((vote) => vote.name === voterName)
|
||||||
));
|
));
|
||||||
if (previousVote) {
|
if (previousVote) {
|
||||||
previousVote.votes = previousVote.votes.filter((vote) => vote.name !== guest.name);
|
previousVote.votes = previousVote.votes.filter((vote) => vote.name !== voterName);
|
||||||
}
|
}
|
||||||
|
|
||||||
option.votes.push({ name: guest.name, timestamp: Date.now() });
|
option.votes.push({ name: voterName, timestamp: Date.now() });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.voters.find((voter) => voter.name === guest.name)) {
|
if (!data.voters.find((voter) => voter.name === voterName)) {
|
||||||
data.voters.push({ name: guest.name, joinedAt: Date.now() });
|
data.voters.push({ name: voterName, joinedAt: Date.now() });
|
||||||
}
|
}
|
||||||
|
|
||||||
saveData(data);
|
saveData(data);
|
||||||
broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() });
|
broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() });
|
||||||
} else if (msg.type === 'add_option') {
|
} else if (msg.type === 'add_option') {
|
||||||
const { categoryId, name, desc, url, lat, lng } = msg;
|
const { categoryId, name, desc, url, voterName, lat, lng } = msg;
|
||||||
const guest = verifyGuestToken(msg.authToken || msg.guestToken || null);
|
if (!categoryId || !name || !voterName) return;
|
||||||
if (!guest || !categoryId || !name) {
|
|
||||||
ws.send(JSON.stringify({ type: 'error', message: 'Guest authentication required' }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newOption = createUserOption({
|
const newOption = createUserOption({
|
||||||
categoryId,
|
categoryId,
|
||||||
name,
|
name,
|
||||||
desc,
|
desc,
|
||||||
url,
|
url,
|
||||||
voterName: guest.name,
|
voterName,
|
||||||
lat,
|
lat,
|
||||||
lng,
|
lng,
|
||||||
approved: true,
|
approved: true,
|
||||||
@@ -1093,8 +354,6 @@ wss.on('connection', (ws) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
const HOST = process.env.HOST || '0.0.0.0';
|
server.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`🏄 Cabo Voting App → http://0.0.0.0:${PORT}`);
|
||||||
server.listen(PORT, HOST, () => {
|
|
||||||
console.log(`🏄 Cabo Voting App → http://${HOST}:${PORT}`);
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user