Compare commits
1 Commits
main
...
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
client/dist/assets/index-B5xoFPr6.css
vendored
1
client/dist/assets/index-B5xoFPr6.css
vendored
File diff suppressed because one or more lines are too long
18
client/dist/assets/index-CY3ZP8YS.js
vendored
18
client/dist/assets/index-CY3ZP8YS.js
vendored
File diff suppressed because one or more lines are too long
13
client/dist/index.html
vendored
13
client/dist/index.html
vendored
@@ -1,13 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Cabo Bachelor Party</title>
|
|
||||||
<script type="module" crossorigin src="/assets/index-CY3ZP8YS.js"></script>
|
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-B5xoFPr6.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Cabo Bachelor Party</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,750 +0,0 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
||||||
import { Navigate, NavLink, Route, Routes, useNavigate, useParams } from 'react-router-dom'
|
|
||||||
import L from 'leaflet'
|
|
||||||
import 'leaflet/dist/leaflet.css'
|
|
||||||
|
|
||||||
const AUTH_TOKEN_KEY = 'cabo_guest_auth_token'
|
|
||||||
const BUNDLE_TAB_ID = 'bundles'
|
|
||||||
const COMPONENT_TABS = new Set(['hotel', 'flight', 'golf', 'nightlife', 'excursion'])
|
|
||||||
const CAT_COLORS = {
|
|
||||||
hotel: '#3b82f6',
|
|
||||||
flight: '#38bdf8',
|
|
||||||
golf: '#22c55e',
|
|
||||||
nightlife: '#a855f7',
|
|
||||||
excursion: '#06b6d4',
|
|
||||||
itinerary: '#fbbf24',
|
|
||||||
budget: '#f97316',
|
|
||||||
}
|
|
||||||
const CAT_EMOJI = {
|
|
||||||
hotel: '🏨',
|
|
||||||
flight: '✈️',
|
|
||||||
golf: '⛳',
|
|
||||||
nightlife: '🎧',
|
|
||||||
excursion: '🚤',
|
|
||||||
itinerary: '🗺️',
|
|
||||||
budget: '💸',
|
|
||||||
}
|
|
||||||
function normalizeSourceKey(value) {
|
|
||||||
return String(value || '')
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
|
||||||
.replace(/^-+|-+$/g, '') || 'unknown-source'
|
|
||||||
}
|
|
||||||
|
|
||||||
function getVotes(option) {
|
|
||||||
if (Array.isArray(option?.votes)) return option.votes
|
|
||||||
if (Array.isArray(option?.voters)) return option.voters.map((name) => ({ name }))
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPackageSource(source) {
|
|
||||||
return String(source?.bookingType || '').toLowerCase() === 'package'
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPackageLink(link) {
|
|
||||||
const text = `${link?.label || ''} ${link?.url || ''}`.toLowerCase()
|
|
||||||
return /\b(costco|apple vacations|cheapcaribbean|package|bundle|flight[- ]?hotel|hotel[- ]?flight)\b/.test(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMoney(value, currency = 'USD') {
|
|
||||||
if (typeof value !== 'number' || Number.isNaN(value)) return ''
|
|
||||||
return new Intl.NumberFormat('en-US', {
|
|
||||||
style: 'currency',
|
|
||||||
currency,
|
|
||||||
maximumFractionDigits: value >= 100 ? 0 : 2,
|
|
||||||
}).format(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(value) {
|
|
||||||
if (!value) return ''
|
|
||||||
const date = new Date(value.includes('T') ? value : `${value}T00:00:00Z`)
|
|
||||||
if (Number.isNaN(date.getTime())) return ''
|
|
||||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: value.includes('-') && !value.includes('T') ? 'numeric' : undefined })
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBookingType(type, basis) {
|
|
||||||
const typeLabel = { package: 'Package', standalone: 'Standalone', calculated: 'Calculated' }[type] || ''
|
|
||||||
const basisLabel = {
|
|
||||||
perTraveler: 'per traveler',
|
|
||||||
perNight: 'per night',
|
|
||||||
perDay: 'per day',
|
|
||||||
perPerson: 'per person',
|
|
||||||
perGroup: 'per group',
|
|
||||||
totalPackage: 'total package',
|
|
||||||
perRound: 'per round',
|
|
||||||
perTable: 'per table',
|
|
||||||
}[basis] || basis || ''
|
|
||||||
return [typeLabel, basisLabel].filter(Boolean).join(' · ')
|
|
||||||
}
|
|
||||||
|
|
||||||
function cx(...classes) {
|
|
||||||
return classes.filter(Boolean).join(' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOptionImageUrl(option, bookingUrl) {
|
|
||||||
const sourceUrl = bookingUrl || option.bookingUrl || option.url || option.links?.[0]?.url || ''
|
|
||||||
if (sourceUrl) return `/api/preview-image?url=${encodeURIComponent(sourceUrl)}`
|
|
||||||
return option.imageUrl || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function useCaboData() {
|
|
||||||
const [state, setState] = useState({
|
|
||||||
categories: [],
|
|
||||||
options: [],
|
|
||||||
budgetScenarios: [],
|
|
||||||
guestRoster: [],
|
|
||||||
pollsOpen: true,
|
|
||||||
totalVoters: 0,
|
|
||||||
priceUpdatedAt: '',
|
|
||||||
priceHistoryRunCount: 0,
|
|
||||||
tripCheckIn: '',
|
|
||||||
tripCheckOut: '',
|
|
||||||
connected: false,
|
|
||||||
})
|
|
||||||
const wsRef = useRef(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let closed = false
|
|
||||||
let retry = 1000
|
|
||||||
let timer = null
|
|
||||||
|
|
||||||
const connect = () => {
|
|
||||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
||||||
const host = location.port === '5173' ? `${location.hostname}:3001` : location.host
|
|
||||||
const ws = new WebSocket(`${protocol}//${host}`)
|
|
||||||
wsRef.current = ws
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
retry = 1000
|
|
||||||
setState((current) => ({ ...current, connected: true }))
|
|
||||||
}
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
const message = JSON.parse(event.data)
|
|
||||||
if (message.type === 'init') {
|
|
||||||
setState((current) => ({
|
|
||||||
...current,
|
|
||||||
categories: message.categories || [],
|
|
||||||
options: message.options || [],
|
|
||||||
budgetScenarios: message.budgetScenarios || [],
|
|
||||||
guestRoster: message.guestRoster || [],
|
|
||||||
pollsOpen: message.pollsOpen,
|
|
||||||
totalVoters: message.totalVoters || 0,
|
|
||||||
priceUpdatedAt: message.priceUpdatedAt || '',
|
|
||||||
priceHistoryRunCount: message.priceHistoryRunCount || 0,
|
|
||||||
tripCheckIn: message.tripCheckIn || '',
|
|
||||||
tripCheckOut: message.tripCheckOut || '',
|
|
||||||
}))
|
|
||||||
} else if (message.type === 'vote_update') {
|
|
||||||
setState((current) => ({
|
|
||||||
...current,
|
|
||||||
options: current.options.map((option) => {
|
|
||||||
const update = message.results?.find((result) => result.id === option.id)
|
|
||||||
return update ? { ...option, votes: (update.voters || []).map((name) => ({ name })) } : option
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
} else if (['option_added', 'option_approved', 'option_updated'].includes(message.type)) {
|
|
||||||
setState((current) => ({
|
|
||||||
...current,
|
|
||||||
options: current.options.some((option) => option.id === message.option.id)
|
|
||||||
? current.options.map((option) => option.id === message.option.id ? { ...option, ...message.option } : option)
|
|
||||||
: [...current.options, message.option],
|
|
||||||
}))
|
|
||||||
} else if (message.type === 'option_deleted') {
|
|
||||||
setState((current) => ({ ...current, options: current.options.filter((option) => option.id !== message.id) }))
|
|
||||||
} else if (message.type === 'polls_status') {
|
|
||||||
setState((current) => ({ ...current, pollsOpen: message.open }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ws.onclose = () => {
|
|
||||||
setState((current) => ({ ...current, connected: false }))
|
|
||||||
if (!closed) {
|
|
||||||
timer = setTimeout(connect, retry)
|
|
||||||
retry = Math.min(retry * 2, 30000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ws.onerror = () => ws.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
connect()
|
|
||||||
return () => {
|
|
||||||
closed = true
|
|
||||||
clearTimeout(timer)
|
|
||||||
wsRef.current?.close()
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const send = (message) => {
|
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
||||||
wsRef.current.send(JSON.stringify(message))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { state, setState, send }
|
|
||||||
}
|
|
||||||
|
|
||||||
function useGuestSession() {
|
|
||||||
const [guest, setGuest] = useState(null)
|
|
||||||
const [token, setToken] = useState(() => localStorage.getItem(AUTH_TOKEN_KEY) || '')
|
|
||||||
const [ready, setReady] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!token) {
|
|
||||||
setReady(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fetch('/api/auth/me', { headers: { Authorization: `Bearer ${token}` } })
|
|
||||||
.then((response) => response.ok ? response.json() : Promise.reject(new Error('invalid')))
|
|
||||||
.then((payload) => setGuest(payload.guest))
|
|
||||||
.catch(() => {
|
|
||||||
localStorage.removeItem(AUTH_TOKEN_KEY)
|
|
||||||
setToken('')
|
|
||||||
setGuest(null)
|
|
||||||
})
|
|
||||||
.finally(() => setReady(true))
|
|
||||||
}, [token])
|
|
||||||
|
|
||||||
const login = async (name, pin) => {
|
|
||||||
const response = await fetch('/api/auth/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ name, pin }),
|
|
||||||
})
|
|
||||||
const payload = await response.json().catch(() => ({}))
|
|
||||||
if (!response.ok) throw new Error(payload.error || 'Invalid guest name or code')
|
|
||||||
localStorage.setItem(AUTH_TOKEN_KEY, payload.token)
|
|
||||||
setToken(payload.token)
|
|
||||||
setGuest(payload.guest)
|
|
||||||
}
|
|
||||||
|
|
||||||
const logout = () => {
|
|
||||||
localStorage.removeItem(AUTH_TOKEN_KEY)
|
|
||||||
setToken('')
|
|
||||||
setGuest(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { guest, token, ready, login, logout }
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTabs(categories) {
|
|
||||||
const tabs = categories.filter((category) => category.id !== 'results').map((category) => ({ ...category }))
|
|
||||||
const hotelIndex = tabs.findIndex((category) => category.id === 'hotel')
|
|
||||||
const bundleTab = { id: BUNDLE_TAB_ID, name: 'Bundles', emoji: '🧳' }
|
|
||||||
if (hotelIndex >= 0) tabs.splice(hotelIndex + 1, 0, bundleTab)
|
|
||||||
else tabs.unshift(bundleTab)
|
|
||||||
tabs.push({ id: 'results', name: 'Results', emoji: '🏆' })
|
|
||||||
return tabs
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAvailableSources(option, tabId) {
|
|
||||||
const sources = Array.isArray(option.availableSources) && option.availableSources.length
|
|
||||||
? option.availableSources.map((source) => ({ ...source, sourceKey: normalizeSourceKey(source.sourceKey || source.sourceLabel || source.source) }))
|
|
||||||
: [{
|
|
||||||
sourceKey: normalizeSourceKey(option.automationInsights?.source || 'unknown-source'),
|
|
||||||
sourceLabel: option.automationInsights?.source || 'Unknown source',
|
|
||||||
sourceUrl: option.automationInsights?.sourceUrl || option.bookingUrl || option.url || null,
|
|
||||||
bookingType: option.automationInsights?.bookingType || null,
|
|
||||||
priceBasis: option.automationInsights?.priceBasis || null,
|
|
||||||
latestPrice: option.latestPricePoint?.price ?? null,
|
|
||||||
latestDisplayPrice: option.latestPricePoint?.displayPrice || null,
|
|
||||||
currency: option.latestPricePoint?.currency || 'USD',
|
|
||||||
}]
|
|
||||||
|
|
||||||
if (tabId === BUNDLE_TAB_ID) return sources.filter(isPackageSource)
|
|
||||||
if (COMPONENT_TABS.has(tabId)) return sources.filter((source) => !isPackageSource(source))
|
|
||||||
return sources
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLatestPoint(option, tabId) {
|
|
||||||
const sources = getAvailableSources(option, tabId)
|
|
||||||
const preferred = sources[0]
|
|
||||||
if (!preferred) return option.latestPricePoint || null
|
|
||||||
const series = option.priceHistoryBySource?.[preferred.sourceKey] || option.priceHistory || []
|
|
||||||
return series.at?.(-1) || option.latestPricePoint || null
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBookingUrl(option, tabId) {
|
|
||||||
const source = getAvailableSources(option, tabId)[0]
|
|
||||||
if (source?.sourceUrl) return source.sourceUrl
|
|
||||||
if (option.bookingUrl) return option.bookingUrl
|
|
||||||
if (option.url) return option.url
|
|
||||||
return option.links?.[0]?.url || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function getVisibleLinks(option, tabId) {
|
|
||||||
const links = Array.isArray(option.links) ? option.links : []
|
|
||||||
if (tabId === BUNDLE_TAB_ID || !COMPONENT_TABS.has(tabId)) return links
|
|
||||||
return links.filter((link) => !isPackageLink(link))
|
|
||||||
}
|
|
||||||
|
|
||||||
function getVisibleOptions(options, tabId) {
|
|
||||||
if (tabId === BUNDLE_TAB_ID) {
|
|
||||||
return options.filter((option) => option.approved && getAvailableSources(option, tabId).length > 0)
|
|
||||||
}
|
|
||||||
return options.filter((option) => option.approved && option.categoryId === tabId)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AppShell({ data, guest, token, send, logout }) {
|
|
||||||
const tabs = useMemo(() => buildTabs(data.categories), [data.categories])
|
|
||||||
const [mobileView, setMobileView] = useState('list')
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (location.pathname === '/') navigate('/hotel', { replace: true })
|
|
||||||
}, [navigate])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-ink text-slate-100">
|
|
||||||
<header className="sticky top-0 z-40 border-b-2 border-aqua bg-gradient-to-r from-panel to-panel2 px-4 py-3">
|
|
||||||
<div className="mx-auto flex max-w-[1440px] items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-base font-black text-aqua md:text-xl">Cabo Bachelor Party</h1>
|
|
||||||
<p className="text-xs text-slate-400">Vote, compare prices, and keep the map in view.</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 text-xs text-slate-300">
|
|
||||||
<span className={cx('h-2 w-2 rounded-full', data.connected ? 'bg-emerald-400' : 'bg-red-400')} />
|
|
||||||
<span>{data.connected ? 'Live' : 'Reconnecting'}</span>
|
|
||||||
{guest && (
|
|
||||||
<button className="rounded-full border border-aqua/40 bg-aqua/10 px-3 py-1 font-bold text-aqua" onClick={logout}>
|
|
||||||
{guest.name}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<nav className="sticky top-[62px] z-30 overflow-x-auto border-b border-line bg-panel/95 backdrop-blur">
|
|
||||||
<div className="mx-auto flex max-w-[1440px]">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<NavLink
|
|
||||||
key={tab.id}
|
|
||||||
to={`/${tab.id}`}
|
|
||||||
className={({ isActive }) => cx(
|
|
||||||
'min-w-24 flex-1 border-b-2 px-3 py-3 text-center text-xs font-bold text-slate-400 transition hover:bg-white/5 hover:text-white',
|
|
||||||
isActive && 'border-aqua bg-aqua/10 text-aqua',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="block text-lg">{tab.emoji}</span>
|
|
||||||
{tab.name}
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main className="mx-auto w-full max-w-[1440px] px-4 py-4">
|
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<Navigate to="/hotel" replace />} />
|
|
||||||
<Route
|
|
||||||
path="/:tabId"
|
|
||||||
element={
|
|
||||||
<MainRoute
|
|
||||||
data={data}
|
|
||||||
tabs={tabs}
|
|
||||||
guest={guest}
|
|
||||||
token={token}
|
|
||||||
send={send}
|
|
||||||
mobileView={mobileView}
|
|
||||||
setMobileView={setMobileView}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Routes>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MainRoute({ data, tabs, guest, token, send, mobileView, setMobileView }) {
|
|
||||||
const { tabId = 'hotel' } = useParams()
|
|
||||||
const tab = tabs.find((candidate) => candidate.id === tabId)
|
|
||||||
if (!tab) return <Navigate to="/hotel" replace />
|
|
||||||
if (tabId === 'results') return <ResultsView data={data} />
|
|
||||||
|
|
||||||
const visibleOptions = getVisibleOptions(data.options, tabId)
|
|
||||||
const showMap = tabId !== 'budget'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{showMap && (
|
|
||||||
<div className="mb-3 grid grid-cols-2 gap-2 rounded-xl border border-line bg-panel p-1 md:hidden">
|
|
||||||
<button className={cx('rounded-lg px-3 py-2 text-xs font-black', mobileView === 'list' ? 'bg-aqua/15 text-aqua' : 'text-slate-400')} onClick={() => setMobileView('list')}>List</button>
|
|
||||||
<button className={cx('rounded-lg px-3 py-2 text-xs font-black', mobileView === 'map' ? 'bg-aqua/15 text-aqua' : 'text-slate-400')} onClick={() => setMobileView('map')}>Map</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={cx('grid gap-4', showMap && 'lg:grid-cols-[minmax(420px,520px)_minmax(0,1fr)] xl:grid-cols-[minmax(520px,640px)_minmax(0,1fr)]')}>
|
|
||||||
<section className={cx(showMap && mobileView === 'map' && 'hidden md:block')}>
|
|
||||||
<OptionList tabId={tabId} tab={tab} options={visibleOptions} data={data} guest={guest} token={token} send={send} />
|
|
||||||
<AddOptionForm categories={data.categories} token={token} guest={guest} />
|
|
||||||
</section>
|
|
||||||
{showMap && (
|
|
||||||
<aside className={cx('md:block', mobileView === 'list' && 'hidden')}>
|
|
||||||
<CaboMap options={data.options.filter((option) => option.approved)} onVote={(optionId, remove) => send({ type: 'vote', optionId, remove, authToken: token })} guest={guest} />
|
|
||||||
</aside>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function OptionList({ tabId, tab, options, data, guest, token, send }) {
|
|
||||||
const [sortMode, setSortMode] = useState('vote-desc')
|
|
||||||
const sorted = useMemo(() => {
|
|
||||||
return [...options].sort((a, b) => {
|
|
||||||
if (sortMode.startsWith('vote')) {
|
|
||||||
const delta = getVotes(a).length - getVotes(b).length
|
|
||||||
return sortMode === 'vote-asc' ? delta : -delta
|
|
||||||
}
|
|
||||||
const aPrice = getLatestPoint(a, tabId)?.price
|
|
||||||
const bPrice = getLatestPoint(b, tabId)?.price
|
|
||||||
const aHas = typeof aPrice === 'number'
|
|
||||||
const bHas = typeof bPrice === 'number'
|
|
||||||
if (aHas && bHas) return sortMode === 'price-asc' ? aPrice - bPrice : bPrice - aPrice
|
|
||||||
if (aHas !== bHas) return aHas ? -1 : 1
|
|
||||||
return 0
|
|
||||||
})
|
|
||||||
}, [options, sortMode, tabId])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3 text-xs text-slate-400">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className={cx('h-2 w-2 rounded-full', data.connected ? 'bg-emerald-400' : 'bg-red-400')} />
|
|
||||||
<span>{data.pollsOpen ? 'Polls open' : 'Polls closed'}</span>
|
|
||||||
<span>{data.totalVoters} voter{data.totalVoters === 1 ? '' : 's'}</span>
|
|
||||||
</div>
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<span className="font-bold uppercase tracking-wide">Sort</span>
|
|
||||||
<select className="rounded-lg border border-aqua/20 bg-panel2 px-3 py-2 text-slate-100" value={sortMode} onChange={(event) => setSortMode(event.target.value)}>
|
|
||||||
<option value="vote-desc">Votes: High to Low</option>
|
|
||||||
<option value="vote-asc">Votes: Low to High</option>
|
|
||||||
<option value="price-asc">Price: Low to High</option>
|
|
||||||
<option value="price-desc">Price: High to Low</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{tabId === 'budget' && <BudgetBoard scenarios={data.budgetScenarios} updatedAt={data.priceUpdatedAt} />}
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{sorted.length ? sorted.map((option) => (
|
|
||||||
<OptionCard key={option.id} option={option} tabId={tabId} guest={guest} token={token} send={send} />
|
|
||||||
)) : (
|
|
||||||
<div className="rounded-xl border border-dashed border-line bg-panel p-10 text-center text-sm text-slate-400">
|
|
||||||
No {tab.name.toLowerCase()} options yet.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function OptionCard({ option, tabId, guest, token, send }) {
|
|
||||||
const votes = getVotes(option)
|
|
||||||
const hasVoted = guest && votes.some((vote) => vote.name === guest.name)
|
|
||||||
const latestPoint = getLatestPoint(option, tabId)
|
|
||||||
const source = getAvailableSources(option, tabId)[0]
|
|
||||||
const bookingUrl = getBookingUrl(option, tabId)
|
|
||||||
const imageUrl = getOptionImageUrl(option, bookingUrl)
|
|
||||||
const links = getVisibleLinks(option, tabId)
|
|
||||||
const price = latestPoint?.displayPrice || (typeof latestPoint?.price === 'number' ? formatMoney(latestPoint.price, latestPoint.currency) : source?.latestDisplayPrice || '')
|
|
||||||
const dates = [formatDate(latestPoint?.tripCheckIn), formatDate(latestPoint?.tripCheckOut)].filter(Boolean).join(' to ')
|
|
||||||
const chips = [
|
|
||||||
...(option.details || []),
|
|
||||||
...(latestPoint?.highlights || []),
|
|
||||||
...(latestPoint?.features || []),
|
|
||||||
].slice(0, 7)
|
|
||||||
|
|
||||||
const vote = () => {
|
|
||||||
if (!guest) return
|
|
||||||
send({ type: 'vote', optionId: option.id, remove: hasVoted, authToken: token })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article className={cx('min-w-0 overflow-hidden rounded-xl border bg-panel shadow-lg transition hover:-translate-y-0.5 hover:border-aqua/70', hasVoted ? 'border-aqua/70' : 'border-line')}>
|
|
||||||
<div className="grid gap-0">
|
|
||||||
<div className="grid h-44 place-items-center overflow-hidden bg-gradient-to-br from-aqua/10 via-panel2 to-gold/10 sm:h-52">
|
|
||||||
{imageUrl ? (
|
|
||||||
<img
|
|
||||||
src={imageUrl}
|
|
||||||
alt={`${option.name} booking site preview`}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="px-6 text-center text-sm font-black uppercase tracking-wider text-slate-500">No booking image</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 p-4">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<h2 className="break-words text-base font-black text-white">{option.name}</h2>
|
|
||||||
<p className="mt-1 break-words text-sm leading-5 text-slate-400">{option.desc}</p>
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0 text-sm font-black text-aqua">{votes.length} vote{votes.length === 1 ? '' : 's'}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 grid gap-2 min-[560px]:grid-cols-2">
|
|
||||||
<Fact label="Current price" value={price || 'Not tracked yet'} sub={dates} />
|
|
||||||
<Fact label="Source" value={source?.sourceLabel || latestPoint?.source || 'Planning data'} />
|
|
||||||
<Fact label="Booking" value={formatBookingType(source?.bookingType || latestPoint?.bookingType, source?.priceBasis || latestPoint?.priceBasis) || 'Option link'} />
|
|
||||||
<Fact label="Status" value={latestPoint?.availability || latestPoint?.decisionNote || 'Available to compare'} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!!chips.length && (
|
|
||||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
|
||||||
{chips.map((chip) => <span key={chip} className="max-w-full rounded-full bg-panel2 px-2.5 py-1 text-[11px] text-slate-300">{chip}</span>)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
|
||||||
{bookingUrl && (
|
|
||||||
<a href={bookingUrl} target="_blank" rel="noreferrer" className="rounded-full border border-gold/40 bg-gold/10 px-3 py-2 text-xs font-black text-amber-100 hover:bg-gold/20">
|
|
||||||
Open {source?.sourceLabel || 'booking'} quote
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{links.slice(0, 4).map((link) => (
|
|
||||||
<a key={`${option.id}-${link.label}`} href={link.url} target="_blank" rel="noreferrer" className="max-w-full rounded-full border border-aqua/20 bg-aqua/10 px-3 py-2 text-xs font-bold text-aqua hover:bg-aqua/20">
|
|
||||||
{link.label}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
<button disabled={!guest} onClick={vote} className={cx('rounded-full border px-3 py-2 text-xs font-black disabled:cursor-not-allowed disabled:opacity-40', hasVoted ? 'border-emerald-400/40 bg-emerald-400/10 text-emerald-100' : 'border-aqua/40 bg-aqua/10 text-aqua')}>
|
|
||||||
{hasVoted ? 'Remove vote' : 'Vote'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 h-1 overflow-hidden rounded-full bg-panel2">
|
|
||||||
<div className="h-full rounded-full" style={{ width: `${Math.min(100, votes.length * 18)}%`, background: CAT_COLORS[option.categoryId] || '#00d4ff' }} />
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-xs text-slate-500">{votes.length ? votes.map((vote) => vote.name).join(', ') : 'No votes yet.'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Fact({ label, value, sub }) {
|
|
||||||
return (
|
|
||||||
<div className="min-w-0 rounded-lg border border-white/5 bg-white/[0.03] p-3">
|
|
||||||
<div className="text-[10px] font-black uppercase tracking-wider text-slate-500">{label}</div>
|
|
||||||
<div className="mt-1 break-words text-sm font-bold leading-5 text-white">{value}</div>
|
|
||||||
{sub && <div className="mt-1 break-words text-xs leading-4 text-slate-500">{sub}</div>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function BudgetBoard({ scenarios, updatedAt }) {
|
|
||||||
if (!scenarios?.length) return null
|
|
||||||
return (
|
|
||||||
<section className="mb-4 rounded-2xl border border-orange-400/25 bg-gradient-to-br from-orange-400/10 via-panel to-cyan-400/10 p-4">
|
|
||||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-black text-orange-100">Budget Cheat Sheet</h2>
|
|
||||||
<p className="text-sm text-orange-100/70">Fresh automation scenarios from the latest price run.</p>
|
|
||||||
</div>
|
|
||||||
{updatedAt && <span className="rounded-full bg-orange-400/10 px-3 py-1 text-xs font-bold text-orange-100">Updated {formatDate(updatedAt)}</span>}
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
|
||||||
{scenarios.slice(0, 3).map((scenario) => (
|
|
||||||
<div key={scenario.id} className="rounded-xl border border-white/10 bg-ink/70 p-4">
|
|
||||||
<div className="text-xs font-black uppercase tracking-wider text-orange-200">{scenario.tier} · {scenario.groupSize}</div>
|
|
||||||
<div className="mt-2 text-2xl font-black">{formatMoney(scenario.perPerson)} pp</div>
|
|
||||||
<div className="text-xs text-orange-100/70">{formatMoney(scenario.groupTotal)} group total</div>
|
|
||||||
<p className="mt-3 text-sm leading-5 text-slate-300">{scenario.summary}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ResultsView({ data }) {
|
|
||||||
const categories = data.categories.filter((category) => !['results'].includes(category.id))
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-4xl">
|
|
||||||
<div className="mb-5 text-center">
|
|
||||||
<h2 className="text-2xl font-black text-aqua">Results</h2>
|
|
||||||
<p className="text-sm text-slate-400">{data.totalVoters} voters · {data.pollsOpen ? 'Polls open' : 'Polls closed'}</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-4">
|
|
||||||
{categories.map((category) => {
|
|
||||||
const options = data.options.filter((option) => option.approved && option.categoryId === category.id).sort((a, b) => getVotes(b).length - getVotes(a).length)
|
|
||||||
if (!options.length) return null
|
|
||||||
const max = Math.max(...options.map((option) => getVotes(option).length), 1)
|
|
||||||
return (
|
|
||||||
<section key={category.id} className="rounded-xl border border-line bg-panel p-4">
|
|
||||||
<h3 className="mb-3 text-sm font-black uppercase tracking-wider text-slate-400">{category.emoji} {category.name}</h3>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
{options.map((option, index) => {
|
|
||||||
const votes = getVotes(option).length
|
|
||||||
return (
|
|
||||||
<div key={option.id} className="grid grid-cols-[32px_1fr_44px] items-center gap-3 text-sm">
|
|
||||||
<span className="font-black text-slate-400">{index + 1}</span>
|
|
||||||
<div>
|
|
||||||
<div className="font-bold text-white">{option.name}</div>
|
|
||||||
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-panel2">
|
|
||||||
<div className="h-full rounded-full" style={{ width: `${(votes / max) * 100}%`, background: CAT_COLORS[category.id] || '#00d4ff' }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="text-right font-black text-aqua">{votes}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CaboMap({ options, guest, onVote }) {
|
|
||||||
const mapRef = useRef(null)
|
|
||||||
const mapElRef = useRef(null)
|
|
||||||
const markerLayerRef = useRef(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!mapElRef.current || mapRef.current) return
|
|
||||||
mapRef.current = L.map(mapElRef.current, { zoomControl: true }).setView([23.065, -109.698], 12)
|
|
||||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
|
||||||
attribution: '© OpenStreetMap, © CARTO',
|
|
||||||
maxZoom: 18,
|
|
||||||
}).addTo(mapRef.current)
|
|
||||||
markerLayerRef.current = L.layerGroup().addTo(mapRef.current)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const map = mapRef.current
|
|
||||||
const layer = markerLayerRef.current
|
|
||||||
if (!map || !layer) return
|
|
||||||
layer.clearLayers()
|
|
||||||
const markers = options.filter((option) => option.lat && option.lng).map((option) => {
|
|
||||||
const color = option.categoryColor || CAT_COLORS[option.categoryId] || '#00d4ff'
|
|
||||||
const emoji = CAT_EMOJI[option.categoryId] || '📍'
|
|
||||||
const icon = L.divIcon({
|
|
||||||
html: `<div style="background:${color};width:34px;height:34px;border-radius:50%;border:2px solid white;display:flex;align-items:center;justify-content:center;font-size:15px;box-shadow:0 4px 14px rgba(0,0,0,.45)">${emoji}</div>`,
|
|
||||||
className: '',
|
|
||||||
iconSize: [34, 34],
|
|
||||||
iconAnchor: [17, 17],
|
|
||||||
})
|
|
||||||
const votes = getVotes(option)
|
|
||||||
const hasVoted = guest && votes.some((vote) => vote.name === guest.name)
|
|
||||||
const marker = L.marker([option.lat, option.lng], { icon })
|
|
||||||
marker.bindPopup(`
|
|
||||||
<div class="cabo-popup">
|
|
||||||
<strong>${option.name}</strong>
|
|
||||||
<p>${option.desc || ''}</p>
|
|
||||||
<a href="${getBookingUrl(option, option.categoryId)}" target="_blank" rel="noreferrer">Book / quote</a>
|
|
||||||
<button data-option-id="${option.id}" data-remove="${hasVoted ? 'true' : 'false'}">${hasVoted ? 'Remove vote' : 'Vote'}</button>
|
|
||||||
</div>
|
|
||||||
`)
|
|
||||||
marker.on('popupopen', (event) => {
|
|
||||||
event.popup.getElement()?.querySelector('button')?.addEventListener('click', () => {
|
|
||||||
onVote(option.id, hasVoted)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
marker.addTo(layer)
|
|
||||||
return marker
|
|
||||||
})
|
|
||||||
if (markers.length) {
|
|
||||||
map.fitBounds(L.featureGroup(markers).getBounds(), { padding: [34, 34], maxZoom: 14 })
|
|
||||||
}
|
|
||||||
setTimeout(() => map.invalidateSize(), 50)
|
|
||||||
}, [options, guest, onVote])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const resize = () => mapRef.current?.invalidateSize()
|
|
||||||
window.addEventListener('resize', resize)
|
|
||||||
setTimeout(resize, 100)
|
|
||||||
return () => window.removeEventListener('resize', resize)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="sticky top-32 h-[calc(100vh-9rem)] min-h-[520px] overflow-hidden rounded-2xl border border-line bg-panel shadow-glow">
|
|
||||||
<div ref={mapElRef} className="h-full w-full" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AddOptionForm({ categories, guest, token }) {
|
|
||||||
const [form, setForm] = useState({ categoryId: 'hotel', name: '', desc: '', url: '', details: '' })
|
|
||||||
const [status, setStatus] = useState('')
|
|
||||||
|
|
||||||
const submit = async (event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
if (!guest) {
|
|
||||||
setStatus('Sign in before suggesting a place.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const response = await fetch('/api/options', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
||||||
body: JSON.stringify(form),
|
|
||||||
})
|
|
||||||
setStatus(response.ok ? 'Suggestion sent.' : 'Could not submit suggestion.')
|
|
||||||
if (response.ok) setForm({ categoryId: 'hotel', name: '', desc: '', url: '', details: '' })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={submit} className="mt-5 rounded-xl border border-dashed border-line bg-panel p-4">
|
|
||||||
<h3 className="mb-3 text-sm font-black text-slate-400">Suggest a Place</h3>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<input className="rounded-lg border border-line bg-panel2 px-3 py-2 text-sm outline-none focus:border-aqua" value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder="Name" />
|
|
||||||
<input className="rounded-lg border border-line bg-panel2 px-3 py-2 text-sm outline-none focus:border-aqua" value={form.desc} onChange={(event) => setForm({ ...form, desc: event.target.value })} placeholder="Short description" />
|
|
||||||
<input className="rounded-lg border border-line bg-panel2 px-3 py-2 text-sm outline-none focus:border-aqua" value={form.url} onChange={(event) => setForm({ ...form, url: event.target.value })} placeholder="Booking or website URL" />
|
|
||||||
<textarea className="min-h-20 rounded-lg border border-line bg-panel2 px-3 py-2 text-sm outline-none focus:border-aqua" value={form.details} onChange={(event) => setForm({ ...form, details: event.target.value })} placeholder="Details, one per line" />
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<select className="min-w-0 flex-1 rounded-lg border border-line bg-panel2 px-3 py-2 text-sm" value={form.categoryId} onChange={(event) => setForm({ ...form, categoryId: event.target.value })}>
|
|
||||||
{categories.filter((category) => !['results'].includes(category.id)).map((category) => <option key={category.id} value={category.id}>{category.emoji} {category.name}</option>)}
|
|
||||||
</select>
|
|
||||||
<button className="rounded-lg bg-aqua px-4 py-2 text-sm font-black text-ink" type="submit">Submit</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{status && <p className="mt-2 text-xs text-slate-400">{status}</p>}
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AuthModal({ roster, login }) {
|
|
||||||
const [name, setName] = useState('')
|
|
||||||
const [pin, setPin] = useState('')
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
|
|
||||||
const submit = async (event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
try {
|
|
||||||
await login(name, pin)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 grid place-items-center bg-black/80 p-4 backdrop-blur">
|
|
||||||
<form onSubmit={submit} className="w-full max-w-sm rounded-2xl border border-line bg-panel p-6 text-center shadow-2xl">
|
|
||||||
<h2 className="text-xl font-black text-aqua">Guest Access</h2>
|
|
||||||
<p className="mt-2 text-sm text-slate-400">Select your name and enter the last 4 digits of your phone number.</p>
|
|
||||||
<select className="mt-5 w-full rounded-lg border border-line bg-panel2 px-3 py-3 text-left outline-none focus:border-aqua" value={name} onChange={(event) => setName(event.target.value)}>
|
|
||||||
<option value="">Select your name</option>
|
|
||||||
{roster.map((guest) => <option key={guest.name} value={guest.name}>{guest.name}</option>)}
|
|
||||||
</select>
|
|
||||||
<input className="mt-3 w-full rounded-lg border border-line bg-panel2 px-3 py-3 text-center text-lg tracking-[0.3em] outline-none focus:border-aqua" inputMode="numeric" maxLength={4} type="password" value={pin} onChange={(event) => setPin(event.target.value.replace(/\D/g, '').slice(0, 4))} placeholder="0000" />
|
|
||||||
{error && <p className="mt-3 text-sm text-red-300">{error}</p>}
|
|
||||||
<button className="mt-5 w-full rounded-lg bg-aqua px-4 py-3 font-black text-ink" type="submit">Join the Vote</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
const { state, send } = useCaboData()
|
|
||||||
const { guest, token, ready, login, logout } = useGuestSession()
|
|
||||||
|
|
||||||
if (!ready) return <div className="grid min-h-screen place-items-center bg-ink text-aqua">Loading...</div>
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<AppShell data={state} guest={guest} token={token} send={send} logout={logout} />
|
|
||||||
{!guest && <AuthModal roster={state.guestRoster} login={login} />}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
html {
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
min-width: 320px;
|
|
||||||
background: #0b0d14;
|
|
||||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaflet-container {
|
|
||||||
background: #0a0a14;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaflet-popup-content-wrapper,
|
|
||||||
.leaflet-popup-tip {
|
|
||||||
background: #13161f;
|
|
||||||
color: #e0e6f0;
|
|
||||||
border: 1px solid #252a38;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cabo-popup {
|
|
||||||
min-width: 190px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cabo-popup strong {
|
|
||||||
display: block;
|
|
||||||
color: #00d4ff;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cabo-popup p {
|
|
||||||
margin: 0 0 8px;
|
|
||||||
color: #9aa6bd;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cabo-popup a,
|
|
||||||
.cabo-popup button {
|
|
||||||
display: inline-flex;
|
|
||||||
margin-right: 6px;
|
|
||||||
margin-top: 6px;
|
|
||||||
border: 1px solid rgba(0, 212, 255, 0.35);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(0, 212, 255, 0.12);
|
|
||||||
color: #bdf4ff;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 800;
|
|
||||||
padding: 6px 9px;
|
|
||||||
text-decoration: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { createRoot } from 'react-dom/client'
|
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
|
||||||
import App from './App.jsx'
|
|
||||||
import './index.css'
|
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<BrowserRouter>
|
|
||||||
<App />
|
|
||||||
</BrowserRouter>
|
|
||||||
</React.StrictMode>,
|
|
||||||
)
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { defineConfig } from 'vite'
|
|
||||||
import react from '@vitejs/plugin-react'
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
root: 'client',
|
|
||||||
build: {
|
|
||||||
outDir: 'dist',
|
|
||||||
emptyOutDir: true,
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
port: 5173,
|
|
||||||
proxy: {
|
|
||||||
'/api': 'http://127.0.0.1:3001',
|
|
||||||
'/ws': {
|
|
||||||
target: 'ws://127.0.0.1:3001',
|
|
||||||
ws: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
2933
package-lock.json
generated
2933
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
32
package.json
32
package.json
@@ -1,32 +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": {
|
|
||||||
"dev": "vite --config client/vite.config.js --host 0.0.0.0",
|
|
||||||
"build": "vite build --config client/vite.config.js",
|
|
||||||
"start": "node server.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"cors": "^2.8.5",
|
|
||||||
"express": "^4.21.2",
|
|
||||||
"leaflet": "^1.9.4",
|
|
||||||
"react": "^19.2.7",
|
|
||||||
"react-dom": "^19.2.7",
|
|
||||||
"react-router-dom": "^7.17.0",
|
|
||||||
"uuid": "^11.1.0",
|
|
||||||
"ws": "^8.18.2"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
|
||||||
"autoprefixer": "^10.5.0",
|
|
||||||
"postcss": "^8.5.15",
|
|
||||||
"tailwindcss": "^3.4.17",
|
|
||||||
"vite": "^8.0.16"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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,36 +0,0 @@
|
|||||||
# Cabo Price Watch - 2026-06-12
|
|
||||||
|
|
||||||
Trip window per current watch contract: 2027-02-02 through 2027-02-06 (4 nights)
|
|
||||||
|
|
||||||
## Biggest Changes
|
|
||||||
|
|
||||||
- CheapCaribbean now exposes a live Los Cabos package ladder: Dreams Los Cabos at $893 pp, Riu Palace Cabo San Lucas at $919 pp, Breathless at $1,091 pp, Secrets at $1,073 pp, Paradisus at $1,127 pp, Grand Fiesta Americana at $1,242 pp, and Hard Rock at $1,373 pp.
|
|
||||||
- Cabo Bash now shows current nightlife and day-club pricing with real tiers for Bagatelle, Taboo, Mandala, La Vaquita, and El Squid Roe.
|
|
||||||
- Golf pricing is still live and usable: Quivira is $360 for the current twilight window that covers February, and Palmilla’s public marketplace rate is $205 for twilight 18 holes.
|
|
||||||
- Cabo Adventures and Cabo Villas both surfaced current water/activity pricing again, including whale watching from $76, private whale watching from $1,504, private sailing from $855.94, and the 46-foot yacht from $1,753.38.
|
|
||||||
|
|
||||||
## Stable Anchors
|
|
||||||
|
|
||||||
- Exact-date KAYAK flight anchors and the Hotwire Hotel Colli stay anchor remained the live standalone baselines from the previous run.
|
|
||||||
- Current KAYAK hotel floors worth tracking are Breathless at $419/night, Secrets at $305/night, Grand Fiesta Americana at $212/night, Solmar Resort at $98/night, Grand Solmar Land’s End at $138/night, Hyatt Ziva at $314/night, and JW Marriott at $331/night.
|
|
||||||
- No sold-out items surfaced in this pass.
|
|
||||||
|
|
||||||
## Missing Or Gated
|
|
||||||
|
|
||||||
- Costco Travel package pricing remains login/continue-gated, but the Los Cabos package lineup is still visible and continues to surface Hacienda del Mar, Grand Fiesta Americana, Secrets, Grand Velas, Hilton, Le Blanc, and related options.
|
|
||||||
- Apple Vacations hotel/package pages continue to expose inclusions and promos, but I could not verify a clean date-matched package price for the target window from the page text available in this pass.
|
|
||||||
- The Cabo Adventures ATV page still hides the clean base price in the captured text, but it does clearly expose the mandatory $25 entrance fee and $35 damage waiver.
|
|
||||||
|
|
||||||
## New Options Worth Adding
|
|
||||||
|
|
||||||
- Add CheapCaribbean package cards for Dreams, Breathless, Secrets, Paradisus, Grand Fiesta Americana, Riu Palace Cabo San Lucas, and Hard Rock.
|
|
||||||
- Add hotel-only cards for Hyatt Ziva, JW Marriott, Solmar Resort, and Grand Solmar Land’s End.
|
|
||||||
- Add nightlife cards for Cabo Bash Taboo, Mandala, and Bagatelle, plus The Cabo Agency’s Cabo Wabo table and booth options.
|
|
||||||
- Add excursion cards for private whale watching and the Cabo Villas private sail/yacht options.
|
|
||||||
|
|
||||||
## Budget Impact
|
|
||||||
|
|
||||||
- Budget: Dreams package + Palmilla + whale watch lands near $1,174 pp for 8 guests.
|
|
||||||
- Balanced: Breathless package + Quivira + private sail + Mandala lands near $1,853.31 pp for 10 guests.
|
|
||||||
- Splurge: Hard Rock package + Quivira + premium yacht + Taboo presidential deck lands near $2,352.45 pp for 12 guests.
|
|
||||||
- Package-vs-standalone caveat: package prices already include flight and hotel, so flight or room-only costs must not be added again unless the package explicitly excludes them.
|
|
||||||
@@ -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-02",
|
|
||||||
"checkOut": "2027-02-06",
|
|
||||||
"nights": 4,
|
|
||||||
"note": "All per-night or per-day rates should be converted to the full check-in/check-out total for comparison, while preserving the unit rate in the display label."
|
|
||||||
},
|
|
||||||
"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
|
||||||
|
|||||||
2005
public/index.html
2005
public/index.html
File diff suppressed because it is too large
Load Diff
242
seed-data.js
242
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',
|
||||||
@@ -176,12 +158,6 @@ const BUDGET_SCENARIOS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function buildOptionImageUrl(option) {
|
|
||||||
if (option.imageUrl) return option.imageUrl;
|
|
||||||
const primaryUrl = option.links?.[0]?.url || option.url || option.bookingUrl || '';
|
|
||||||
return primaryUrl ? `/api/preview-image?url=${encodeURIComponent(primaryUrl)}` : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createOption(option) {
|
function createOption(option) {
|
||||||
const categoryColor = CATEGORY_META[option.categoryId]?.color || '#888';
|
const categoryColor = CATEGORY_META[option.categoryId]?.color || '#888';
|
||||||
const primaryUrl = option.links?.[0]?.url || option.url || null;
|
const primaryUrl = option.links?.[0]?.url || option.url || null;
|
||||||
@@ -193,13 +169,9 @@ function createOption(option) {
|
|||||||
links: [],
|
links: [],
|
||||||
categoryColor,
|
categoryColor,
|
||||||
url: primaryUrl,
|
url: primaryUrl,
|
||||||
bookingUrl: option.bookingUrl || primaryUrl,
|
|
||||||
imageUrl: buildOptionImageUrl(option),
|
|
||||||
...option,
|
...option,
|
||||||
categoryColor,
|
categoryColor,
|
||||||
url: primaryUrl,
|
url: primaryUrl,
|
||||||
bookingUrl: option.bookingUrl || primaryUrl,
|
|
||||||
imageUrl: buildOptionImageUrl(option),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,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: '🚤' },
|
||||||
@@ -217,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({
|
||||||
@@ -228,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' },
|
||||||
@@ -243,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-02' },
|
{ 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({
|
||||||
@@ -259,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-02&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' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
@@ -275,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-02&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' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
@@ -297,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-02/2027-02-06?sort=bestflight_a' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
createOption({
|
|
||||||
id: 'flight-ont-sjd-kayak-best',
|
|
||||||
seedKey: 'flight-ont-sjd-kayak-best',
|
|
||||||
categoryId: 'flight',
|
|
||||||
name: 'ONT to SJD on KAYAK - Best',
|
|
||||||
desc: 'Higher-comfort exact-date round-trip flight quote for the target trip window.',
|
|
||||||
details: ['$605 RT pp', 'American, 1 stop', 'Best-value comfort anchor'],
|
|
||||||
links: [
|
|
||||||
{ label: 'KAYAK Flight Search', url: 'https://www.kayak.com/flights/ONT-SJD/2027-02-02/2027-02-06?sort=bestflight_a' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
createOption({
|
createOption({
|
||||||
id: 'golf-palmilla',
|
id: 'golf-palmilla',
|
||||||
seedKey: 'golf-palmilla',
|
seedKey: 'golf-palmilla',
|
||||||
@@ -674,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' },
|
||||||
],
|
],
|
||||||
@@ -729,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' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
@@ -788,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,
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
module.exports = {
|
|
||||||
content: ['./client/index.html', './client/src/**/*.{js,jsx}'],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
ink: '#0b0d14',
|
|
||||||
panel: '#13161f',
|
|
||||||
panel2: '#1a1e2a',
|
|
||||||
line: '#252a38',
|
|
||||||
aqua: '#00d4ff',
|
|
||||||
gold: '#fbbf24',
|
|
||||||
},
|
|
||||||
boxShadow: {
|
|
||||||
glow: '0 18px 60px rgba(0, 212, 255, 0.14)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user