Refactor app to React Router and Tailwind
This commit is contained in:
1
client/dist/assets/index-DKrFtyUx.css
vendored
Normal file
1
client/dist/assets/index-DKrFtyUx.css
vendored
Normal file
File diff suppressed because one or more lines are too long
18
client/dist/assets/index-OJVYK787.js
vendored
Normal file
18
client/dist/assets/index-OJVYK787.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
client/dist/index.html
vendored
Normal file
13
client/dist/index.html
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Cabo Bachelor Party</title>
|
||||
<script type="module" crossorigin src="/assets/index-OJVYK787.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DKrFtyUx.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
12
client/index.html
Normal file
12
client/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Cabo Bachelor Party</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
6
client/postcss.config.js
Normal file
6
client/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -1,256 +1,735 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import Header from './components/Header'
|
||||
import NameModal from './components/NameModal'
|
||||
import TabBar from './components/TabBar'
|
||||
import OptionList from './components/OptionList'
|
||||
import ResultsTab from './components/ResultsTab'
|
||||
import AddOption from './components/AddOption'
|
||||
import Toast from './components/Toast'
|
||||
import WsOverlay from './components/WsOverlay'
|
||||
import OptionModal from './components/OptionModal'
|
||||
import YourVotesModal from './components/YourVotesModal'
|
||||
import SocialToast from './components/SocialToast'
|
||||
import MapTab from './components/MapTab'
|
||||
import BudgetTab from './components/BudgetTab'
|
||||
import { useWebSocket } from './hooks/useWebSocket'
|
||||
import { useVoterSession } from './hooks/useVoterSession'
|
||||
import { useSound } from './hooks/useSound'
|
||||
import './App.css'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Navigate, NavLink, Route, Routes, useNavigate, useParams } from 'react-router-dom'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
|
||||
const SOUND_KEY = 'cabo-voting-sound'
|
||||
const THEME_KEY = 'cabo-voting-theme'
|
||||
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: '💸',
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [categories, setCategories] = useState([])
|
||||
const [options, setOptions] = useState([])
|
||||
const [pollsOpen, setPollsOpen] = useState(true)
|
||||
const [totalVoters, setTotalVoters] = useState(0)
|
||||
const [activeTab, setActiveTab] = useState('hotel')
|
||||
const [toast, setToast] = useState(null)
|
||||
const [selectedOption, setSelectedOption] = useState(null) // detail modal
|
||||
const [yourVotesOpen, setYourVotesOpen] = useState(false) // your votes modal
|
||||
const [socialToast, setSocialToast] = useState(null) // floating vote toast
|
||||
const [soundEnabled, setSoundEnabled] = useState(() => localStorage.getItem(SOUND_KEY) !== 'off')
|
||||
const [theme, setTheme] = useState(() => localStorage.getItem(THEME_KEY) || 'dark')
|
||||
const [onlineCount, setOnlineCount] = useState(0)
|
||||
const [pollDeadline, setPollDeadline] = useState(null)
|
||||
function normalizeSourceKey(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'unknown-source'
|
||||
}
|
||||
|
||||
const socialToastTimer = useRef(null)
|
||||
const { voterName, setVoterName, clearVoter } = useVoterSession()
|
||||
const { playVoteSound, playRemoveSound } = useSound()
|
||||
function getVotes(option) {
|
||||
if (Array.isArray(option?.votes)) return option.votes
|
||||
if (Array.isArray(option?.voters)) return option.voters.map((name) => ({ name }))
|
||||
return []
|
||||
}
|
||||
|
||||
const pollsExpired = pollDeadline && Date.now() > new Date(pollDeadline).getTime()
|
||||
function isPackageSource(source) {
|
||||
return String(source?.bookingType || '').toLowerCase() === 'package'
|
||||
}
|
||||
|
||||
const { wsRef, wsConnected, reconnect } = useWebSocket({
|
||||
setCategories, setOptions, setPollsOpen, setTotalVoters,
|
||||
setOnlineCount, setPollDeadline,
|
||||
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 useCaboData() {
|
||||
const [state, setState] = useState({
|
||||
categories: [],
|
||||
options: [],
|
||||
budgetScenarios: [],
|
||||
guestRoster: [],
|
||||
pollsOpen: true,
|
||||
totalVoters: 0,
|
||||
priceUpdatedAt: '',
|
||||
priceHistoryRunCount: 0,
|
||||
tripCheckIn: '',
|
||||
tripCheckOut: '',
|
||||
connected: false,
|
||||
})
|
||||
const wsRef = useRef(null)
|
||||
|
||||
// Apply theme to body
|
||||
useEffect(() => {
|
||||
document.body.dataset.theme = theme
|
||||
localStorage.setItem(THEME_KEY, theme)
|
||||
}, [theme])
|
||||
let closed = false
|
||||
let retry = 1000
|
||||
let timer = null
|
||||
|
||||
const showToast = useCallback((msg, type = '') => {
|
||||
setToast({ msg, type })
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
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 handleVote = useCallback((option, removed = false) => {
|
||||
if (!voterName) return
|
||||
if (!pollsOpen || pollsExpired) return
|
||||
const opt = options.find(o => o.id === option.id)
|
||||
if (!opt) return
|
||||
|
||||
const alreadyVoted = opt.votes?.some(v => v.name === voterName)
|
||||
|
||||
// Optimistic update
|
||||
setOptions(prev => prev.map(o => {
|
||||
if (o.id !== option.id) return o
|
||||
if (removed || alreadyVoted) {
|
||||
return { ...o, votes: o.votes.filter(v => v.name !== voterName) }
|
||||
} else {
|
||||
return { ...o, votes: [...(o.votes || []), { name: voterName, timestamp: Date.now() }] }
|
||||
}
|
||||
}))
|
||||
|
||||
const ws = wsRef.current
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'vote', optionId: option.id, voterName, remove: removed || alreadyVoted }))
|
||||
const send = (message) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify(message))
|
||||
}
|
||||
}
|
||||
|
||||
if (removed || alreadyVoted) {
|
||||
playRemoveSound()
|
||||
showToast(`Removed vote for ${opt.name}`)
|
||||
} else {
|
||||
playVoteSound()
|
||||
showToast(`Voted for ${opt.name}!`)
|
||||
}
|
||||
return { state, setState, send }
|
||||
}
|
||||
|
||||
// Social toast for others (shows voter name + option name)
|
||||
setSocialToast({ voterName, optionName: opt.name, categoryId: opt.categoryId })
|
||||
clearTimeout(socialToastTimer.current)
|
||||
socialToastTimer.current = setTimeout(() => setSocialToast(null), 3000)
|
||||
}, [voterName, options, pollsOpen, pollsExpired, wsRef, playVoteSound, playRemoveSound, showToast])
|
||||
function useGuestSession() {
|
||||
const [guest, setGuest] = useState(null)
|
||||
const [token, setToken] = useState(() => localStorage.getItem(AUTH_TOKEN_KEY) || '')
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
const handleRemoveVote = useCallback((optionId) => {
|
||||
const opt = options.find(o => o.id === optionId)
|
||||
if (opt) handleVote(opt, true)
|
||||
}, [options, handleVote])
|
||||
|
||||
// Check URL params on load
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search)
|
||||
if (params.get('view') === 'results') setActiveTab('results')
|
||||
const optionId = params.get('option')
|
||||
if (optionId) {
|
||||
// Wait for options to load, then open modal
|
||||
const check = () => {
|
||||
const opt = options.find(o => o.id === optionId)
|
||||
if (opt) { setSelectedOption(opt); } else if (options.length > 0) { setSelectedOption(null) }
|
||||
}
|
||||
check()
|
||||
if (!token) {
|
||||
setReady(true)
|
||||
return
|
||||
}
|
||||
}, []) // eslint-disable-line
|
||||
fetch('/api/auth/me', { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then((response) => response.ok ? response.json() : Promise.reject(new Error('invalid')))
|
||||
.then((payload) => setGuest(payload.guest))
|
||||
.catch(() => {
|
||||
localStorage.removeItem(AUTH_TOKEN_KEY)
|
||||
setToken('')
|
||||
setGuest(null)
|
||||
})
|
||||
.finally(() => setReady(true))
|
||||
}, [token])
|
||||
|
||||
const handleAddSubmit = useCallback((data) => {
|
||||
if (!voterName) { showToast('Enter your name first', 'error'); return }
|
||||
const ws = wsRef.current
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'add_option', ...data, voterName }))
|
||||
}
|
||||
document.getElementById('add-name').value = ''
|
||||
document.getElementById('add-desc').value = ''
|
||||
document.getElementById('add-url').value = ''
|
||||
showToast(`Submitted "${data.name}" for approval!`, 'success')
|
||||
}, [voterName, wsRef, showToast])
|
||||
|
||||
const toggleSound = useCallback(() => {
|
||||
setSoundEnabled(prev => {
|
||||
const next = !prev
|
||||
localStorage.setItem(SOUND_KEY, next ? 'on' : 'off')
|
||||
return next
|
||||
const 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 toggleTheme = useCallback(() => {
|
||||
setTheme(prev => prev === 'dark' ? 'light' : 'dark')
|
||||
}, [])
|
||||
const logout = () => {
|
||||
localStorage.removeItem(AUTH_TOKEN_KEY)
|
||||
setToken('')
|
||||
setGuest(null)
|
||||
}
|
||||
|
||||
const votingCats = categories.filter(c => c.id !== 'results')
|
||||
const optionCounts = votingCats.reduce((acc, cat) => {
|
||||
acc[cat.id] = options.filter(o => o.categoryId === cat.id).length
|
||||
return acc
|
||||
}, {})
|
||||
return { guest, token, ready, login, logout }
|
||||
}
|
||||
|
||||
// "Your Votes" — all options this voter voted for
|
||||
const yourVotes = options.filter(o => o.votes?.some(v => v.name === voterName))
|
||||
function buildTabs(categories) {
|
||||
const tabs = categories.filter((category) => category.id !== 'results').map((category) => ({ ...category }))
|
||||
const hotelIndex = tabs.findIndex((category) => category.id === 'hotel')
|
||||
const bundleTab = { id: BUNDLE_TAB_ID, name: 'Bundles', emoji: '🧳' }
|
||||
if (hotelIndex >= 0) tabs.splice(hotelIndex + 1, 0, bundleTab)
|
||||
else tabs.unshift(bundleTab)
|
||||
tabs.push({ id: 'results', name: 'Results', emoji: '🏆' })
|
||||
return tabs
|
||||
}
|
||||
|
||||
function getAvailableSources(option, tabId) {
|
||||
const sources = Array.isArray(option.availableSources) && option.availableSources.length
|
||||
? option.availableSources.map((source) => ({ ...source, sourceKey: normalizeSourceKey(source.sourceKey || source.sourceLabel || source.source) }))
|
||||
: [{
|
||||
sourceKey: normalizeSourceKey(option.automationInsights?.source || 'unknown-source'),
|
||||
sourceLabel: option.automationInsights?.source || 'Unknown source',
|
||||
sourceUrl: option.automationInsights?.sourceUrl || option.bookingUrl || option.url || null,
|
||||
bookingType: option.automationInsights?.bookingType || null,
|
||||
priceBasis: option.automationInsights?.priceBasis || null,
|
||||
latestPrice: option.latestPricePoint?.price ?? null,
|
||||
latestDisplayPrice: option.latestPricePoint?.displayPrice || null,
|
||||
currency: option.latestPricePoint?.currency || 'USD',
|
||||
}]
|
||||
|
||||
if (tabId === BUNDLE_TAB_ID) return sources.filter(isPackageSource)
|
||||
if (COMPONENT_TABS.has(tabId)) return sources.filter((source) => !isPackageSource(source))
|
||||
return sources
|
||||
}
|
||||
|
||||
function getLatestPoint(option, tabId) {
|
||||
const sources = getAvailableSources(option, tabId)
|
||||
const preferred = sources[0]
|
||||
if (!preferred) return option.latestPricePoint || null
|
||||
const series = option.priceHistoryBySource?.[preferred.sourceKey] || option.priceHistory || []
|
||||
return series.at?.(-1) || option.latestPricePoint || null
|
||||
}
|
||||
|
||||
function getBookingUrl(option, tabId) {
|
||||
const source = getAvailableSources(option, tabId)[0]
|
||||
if (source?.sourceUrl) return source.sourceUrl
|
||||
if (option.bookingUrl) return option.bookingUrl
|
||||
if (option.url) return option.url
|
||||
return option.links?.[0]?.url || ''
|
||||
}
|
||||
|
||||
function getVisibleLinks(option, tabId) {
|
||||
const links = Array.isArray(option.links) ? option.links : []
|
||||
if (tabId === BUNDLE_TAB_ID || !COMPONENT_TABS.has(tabId)) return links
|
||||
return links.filter((link) => !isPackageLink(link))
|
||||
}
|
||||
|
||||
function getVisibleOptions(options, tabId) {
|
||||
if (tabId === BUNDLE_TAB_ID) {
|
||||
return options.filter((option) => option.approved && getAvailableSources(option, tabId).length > 0)
|
||||
}
|
||||
return options.filter((option) => option.approved && option.categoryId === tabId)
|
||||
}
|
||||
|
||||
function AppShell({ data, guest, token, send, logout }) {
|
||||
const tabs = useMemo(() => buildTabs(data.categories), [data.categories])
|
||||
const [mobileView, setMobileView] = useState('list')
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (location.pathname === '/') navigate('/hotel', { replace: true })
|
||||
}, [navigate])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-ink text-slate-100">
|
||||
<header className="sticky top-0 z-40 border-b-2 border-aqua bg-gradient-to-r from-panel to-panel2 px-4 py-3">
|
||||
<div className="mx-auto flex max-w-[1440px] items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-base font-black text-aqua md:text-xl">Cabo Bachelor Party</h1>
|
||||
<p className="text-xs text-slate-400">Vote, compare prices, and keep the map in view.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-slate-300">
|
||||
<span className={cx('h-2 w-2 rounded-full', data.connected ? 'bg-emerald-400' : 'bg-red-400')} />
|
||||
<span>{data.connected ? 'Live' : 'Reconnecting'}</span>
|
||||
{guest && (
|
||||
<button className="rounded-full border border-aqua/40 bg-aqua/10 px-3 py-1 font-bold text-aqua" onClick={logout}>
|
||||
{guest.name}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className="sticky top-[62px] z-30 overflow-x-auto border-b border-line bg-panel/95 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-[1440px]">
|
||||
{tabs.map((tab) => (
|
||||
<NavLink
|
||||
key={tab.id}
|
||||
to={`/${tab.id}`}
|
||||
className={({ isActive }) => cx(
|
||||
'min-w-24 flex-1 border-b-2 px-3 py-3 text-center text-xs font-bold text-slate-400 transition hover:bg-white/5 hover:text-white',
|
||||
isActive && 'border-aqua bg-aqua/10 text-aqua',
|
||||
)}
|
||||
>
|
||||
<span className="block text-lg">{tab.emoji}</span>
|
||||
{tab.name}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="mx-auto w-full max-w-[1440px] px-4 py-4">
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/hotel" replace />} />
|
||||
<Route
|
||||
path="/:tabId"
|
||||
element={
|
||||
<MainRoute
|
||||
data={data}
|
||||
tabs={tabs}
|
||||
guest={guest}
|
||||
token={token}
|
||||
send={send}
|
||||
mobileView={mobileView}
|
||||
setMobileView={setMobileView}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MainRoute({ data, tabs, guest, token, send, mobileView, setMobileView }) {
|
||||
const { tabId = 'hotel' } = useParams()
|
||||
const tab = tabs.find((candidate) => candidate.id === tabId)
|
||||
if (!tab) return <Navigate to="/hotel" replace />
|
||||
if (tabId === 'results') return <ResultsView data={data} />
|
||||
|
||||
const visibleOptions = getVisibleOptions(data.options, tabId)
|
||||
const showMap = tabId !== 'budget'
|
||||
|
||||
return (
|
||||
<>
|
||||
<a className="skip-link" href="#main">Skip to content</a>
|
||||
{showMap && (
|
||||
<div className="mb-3 grid grid-cols-2 gap-2 rounded-xl border border-line bg-panel p-1 md:hidden">
|
||||
<button className={cx('rounded-lg px-3 py-2 text-xs font-black', mobileView === 'list' ? 'bg-aqua/15 text-aqua' : 'text-slate-400')} onClick={() => setMobileView('list')}>List</button>
|
||||
<button className={cx('rounded-lg px-3 py-2 text-xs font-black', mobileView === 'map' ? 'bg-aqua/15 text-aqua' : 'text-slate-400')} onClick={() => setMobileView('map')}>Map</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Header
|
||||
voterName={voterName}
|
||||
pollsOpen={pollsOpen}
|
||||
totalVoters={totalVoters}
|
||||
wsConnected={wsConnected}
|
||||
onChangeName={clearVoter}
|
||||
soundEnabled={soundEnabled}
|
||||
onToggleSound={toggleSound}
|
||||
theme={theme}
|
||||
onToggleTheme={toggleTheme}
|
||||
onlineCount={onlineCount}
|
||||
pollDeadline={pollDeadline}
|
||||
pollsExpired={pollsExpired}
|
||||
/>
|
||||
|
||||
{!voterName && <NameModal onSubmit={setVoterName} />}
|
||||
|
||||
<TabBar
|
||||
categories={categories}
|
||||
activeTab={activeTab}
|
||||
onTab={setActiveTab}
|
||||
optionCounts={optionCounts}
|
||||
onYourVotes={() => setYourVotesOpen(true)}
|
||||
voterName={voterName}
|
||||
yourVotesCount={yourVotes.length}
|
||||
/>
|
||||
|
||||
<main id="main">
|
||||
{activeTab === 'results' ? (
|
||||
<ResultsTab
|
||||
categories={categories}
|
||||
options={options}
|
||||
pollsOpen={pollsOpen}
|
||||
totalVoters={totalVoters}
|
||||
/>
|
||||
) : activeTab === 'map' ? (
|
||||
<MapTab
|
||||
options={options}
|
||||
categories={categories}
|
||||
onSelectOption={setSelectedOption}
|
||||
/>
|
||||
) : activeTab === 'budget' ? (
|
||||
<BudgetTab
|
||||
options={options}
|
||||
categories={categories}
|
||||
/>
|
||||
) : (
|
||||
<OptionList
|
||||
options={options.filter(o => o.categoryId === activeTab && o.approved)}
|
||||
voterName={voterName}
|
||||
pollsOpen={pollsOpen}
|
||||
pollsExpired={pollsExpired}
|
||||
onVote={handleVote}
|
||||
onCardClick={setSelectedOption}
|
||||
categoryId={activeTab}
|
||||
/>
|
||||
<div className={cx('grid gap-4', showMap && 'md:grid-cols-[minmax(0,1fr)_minmax(360px,42vw)]')}>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{activeTab !== 'results' && activeTab !== 'map' && activeTab !== 'budget' && (
|
||||
<AddOption
|
||||
categories={categories}
|
||||
voterName={voterName}
|
||||
onSubmit={handleAddSubmit}
|
||||
onNeedsName={() => showToast('Enter your name first', 'error')}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Option detail modal */}
|
||||
{selectedOption && (
|
||||
<OptionModal
|
||||
option={selectedOption}
|
||||
voterName={voterName}
|
||||
onClose={() => setSelectedOption(null)}
|
||||
onVote={handleVote}
|
||||
categories={categories}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Your votes modal */}
|
||||
{yourVotesOpen && (
|
||||
<YourVotesModal
|
||||
votes={yourVotes}
|
||||
voterName={voterName}
|
||||
onClose={() => setYourVotesOpen(false)}
|
||||
onRemoveVote={handleRemoveVote}
|
||||
onViewOption={setSelectedOption}
|
||||
categories={categories}
|
||||
/>
|
||||
)}
|
||||
|
||||
<WsOverlay connected={wsConnected} onReconnect={reconnect} />
|
||||
{toast && <Toast msg={toast.msg} type={toast.type} />}
|
||||
{socialToast && (
|
||||
<SocialToast
|
||||
voterName={socialToast.voterName}
|
||||
optionName={socialToast.optionName}
|
||||
categoryId={socialToast.categoryId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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 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('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 md:grid-cols-[minmax(132px,34%)_1fr]">
|
||||
<div className="min-h-44 bg-panel2">
|
||||
<img src={option.imageUrl} alt="" className="h-full min-h-44 w-full object-cover" loading="lazy" />
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-black text-white">{option.name}</h2>
|
||||
<p className="mt-1 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 sm: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="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">
|
||||
Book / quote
|
||||
</a>
|
||||
)}
|
||||
{links.slice(0, 4).map((link) => (
|
||||
<a key={`${option.id}-${link.label}`} href={link.url} target="_blank" rel="noreferrer" className="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="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 text-sm font-bold text-white">{value}</div>
|
||||
{sub && <div className="mt-1 text-xs text-slate-500">{sub}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BudgetBoard({ scenarios, updatedAt }) {
|
||||
if (!scenarios?.length) return null
|
||||
return (
|
||||
<section className="mb-4 rounded-2xl border border-orange-400/25 bg-gradient-to-br from-orange-400/10 via-panel to-cyan-400/10 p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h2 className="text-lg font-black text-orange-100">Budget Cheat Sheet</h2>
|
||||
<p className="text-sm text-orange-100/70">Fresh automation scenarios from the latest price run.</p>
|
||||
</div>
|
||||
{updatedAt && <span className="rounded-full bg-orange-400/10 px-3 py-1 text-xs font-bold text-orange-100">Updated {formatDate(updatedAt)}</span>}
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{scenarios.slice(0, 3).map((scenario) => (
|
||||
<div key={scenario.id} className="rounded-xl border border-white/10 bg-ink/70 p-4">
|
||||
<div className="text-xs font-black uppercase tracking-wider text-orange-200">{scenario.tier} · {scenario.groupSize}</div>
|
||||
<div className="mt-2 text-2xl font-black">{formatMoney(scenario.perPerson)} pp</div>
|
||||
<div className="text-xs text-orange-100/70">{formatMoney(scenario.groupTotal)} group total</div>
|
||||
<p className="mt-3 text-sm leading-5 text-slate-300">{scenario.summary}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ResultsView({ data }) {
|
||||
const categories = data.categories.filter((category) => !['results'].includes(category.id))
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="mb-5 text-center">
|
||||
<h2 className="text-2xl font-black text-aqua">Results</h2>
|
||||
<p className="text-sm text-slate-400">{data.totalVoters} voters · {data.pollsOpen ? 'Polls open' : 'Polls closed'}</p>
|
||||
</div>
|
||||
<div className="grid gap-4">
|
||||
{categories.map((category) => {
|
||||
const options = data.options.filter((option) => option.approved && option.categoryId === category.id).sort((a, b) => getVotes(b).length - getVotes(a).length)
|
||||
if (!options.length) return null
|
||||
const max = Math.max(...options.map((option) => getVotes(option).length), 1)
|
||||
return (
|
||||
<section key={category.id} className="rounded-xl border border-line bg-panel p-4">
|
||||
<h3 className="mb-3 text-sm font-black uppercase tracking-wider text-slate-400">{category.emoji} {category.name}</h3>
|
||||
<div className="grid gap-2">
|
||||
{options.map((option, index) => {
|
||||
const votes = getVotes(option).length
|
||||
return (
|
||||
<div key={option.id} className="grid grid-cols-[32px_1fr_44px] items-center gap-3 text-sm">
|
||||
<span className="font-black text-slate-400">{index + 1}</span>
|
||||
<div>
|
||||
<div className="font-bold text-white">{option.name}</div>
|
||||
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-panel2">
|
||||
<div className="h-full rounded-full" style={{ width: `${(votes / max) * 100}%`, background: CAT_COLORS[category.id] || '#00d4ff' }} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-right font-black text-aqua">{votes}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CaboMap({ options, guest, onVote }) {
|
||||
const mapRef = useRef(null)
|
||||
const mapElRef = useRef(null)
|
||||
const markerLayerRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapElRef.current || mapRef.current) return
|
||||
mapRef.current = L.map(mapElRef.current, { zoomControl: true }).setView([23.065, -109.698], 12)
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© OpenStreetMap, © CARTO',
|
||||
maxZoom: 18,
|
||||
}).addTo(mapRef.current)
|
||||
markerLayerRef.current = L.layerGroup().addTo(mapRef.current)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
const layer = markerLayerRef.current
|
||||
if (!map || !layer) return
|
||||
layer.clearLayers()
|
||||
const markers = options.filter((option) => option.lat && option.lng).map((option) => {
|
||||
const color = option.categoryColor || CAT_COLORS[option.categoryId] || '#00d4ff'
|
||||
const emoji = CAT_EMOJI[option.categoryId] || '📍'
|
||||
const icon = L.divIcon({
|
||||
html: `<div style="background:${color};width:34px;height:34px;border-radius:50%;border:2px solid white;display:flex;align-items:center;justify-content:center;font-size:15px;box-shadow:0 4px 14px rgba(0,0,0,.45)">${emoji}</div>`,
|
||||
className: '',
|
||||
iconSize: [34, 34],
|
||||
iconAnchor: [17, 17],
|
||||
})
|
||||
const votes = getVotes(option)
|
||||
const hasVoted = guest && votes.some((vote) => vote.name === guest.name)
|
||||
const marker = L.marker([option.lat, option.lng], { icon })
|
||||
marker.bindPopup(`
|
||||
<div class="cabo-popup">
|
||||
<strong>${option.name}</strong>
|
||||
<p>${option.desc || ''}</p>
|
||||
<a href="${getBookingUrl(option, option.categoryId)}" target="_blank" rel="noreferrer">Book / quote</a>
|
||||
<button data-option-id="${option.id}" data-remove="${hasVoted ? 'true' : 'false'}">${hasVoted ? 'Remove vote' : 'Vote'}</button>
|
||||
</div>
|
||||
`)
|
||||
marker.on('popupopen', (event) => {
|
||||
event.popup.getElement()?.querySelector('button')?.addEventListener('click', () => {
|
||||
onVote(option.id, hasVoted)
|
||||
})
|
||||
})
|
||||
marker.addTo(layer)
|
||||
return marker
|
||||
})
|
||||
if (markers.length) {
|
||||
map.fitBounds(L.featureGroup(markers).getBounds(), { padding: [34, 34], maxZoom: 14 })
|
||||
}
|
||||
setTimeout(() => map.invalidateSize(), 50)
|
||||
}, [options, guest, onVote])
|
||||
|
||||
useEffect(() => {
|
||||
const resize = () => mapRef.current?.invalidateSize()
|
||||
window.addEventListener('resize', resize)
|
||||
setTimeout(resize, 100)
|
||||
return () => window.removeEventListener('resize', resize)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="sticky top-32 h-[calc(100vh-9rem)] min-h-[520px] overflow-hidden rounded-2xl border border-line bg-panel shadow-glow">
|
||||
<div ref={mapElRef} className="h-full w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddOptionForm({ categories, guest, token }) {
|
||||
const [form, setForm] = useState({ categoryId: 'hotel', name: '', desc: '', url: '', details: '' })
|
||||
const [status, setStatus] = useState('')
|
||||
|
||||
const submit = async (event) => {
|
||||
event.preventDefault()
|
||||
if (!guest) {
|
||||
setStatus('Sign in before suggesting a place.')
|
||||
return
|
||||
}
|
||||
const response = await fetch('/api/options', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
setStatus(response.ok ? 'Suggestion sent.' : 'Could not submit suggestion.')
|
||||
if (response.ok) setForm({ categoryId: 'hotel', name: '', desc: '', url: '', details: '' })
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="mt-5 rounded-xl border border-dashed border-line bg-panel p-4">
|
||||
<h3 className="mb-3 text-sm font-black text-slate-400">Suggest a Place</h3>
|
||||
<div className="grid gap-2">
|
||||
<input className="rounded-lg border border-line bg-panel2 px-3 py-2 text-sm outline-none focus:border-aqua" value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder="Name" />
|
||||
<input className="rounded-lg border border-line bg-panel2 px-3 py-2 text-sm outline-none focus:border-aqua" value={form.desc} onChange={(event) => setForm({ ...form, desc: event.target.value })} placeholder="Short description" />
|
||||
<input className="rounded-lg border border-line bg-panel2 px-3 py-2 text-sm outline-none focus:border-aqua" value={form.url} onChange={(event) => setForm({ ...form, url: event.target.value })} placeholder="Booking or website URL" />
|
||||
<textarea className="min-h-20 rounded-lg border border-line bg-panel2 px-3 py-2 text-sm outline-none focus:border-aqua" value={form.details} onChange={(event) => setForm({ ...form, details: event.target.value })} placeholder="Details, one per line" />
|
||||
<div className="flex gap-2">
|
||||
<select className="min-w-0 flex-1 rounded-lg border border-line bg-panel2 px-3 py-2 text-sm" value={form.categoryId} onChange={(event) => setForm({ ...form, categoryId: event.target.value })}>
|
||||
{categories.filter((category) => !['results'].includes(category.id)).map((category) => <option key={category.id} value={category.id}>{category.emoji} {category.name}</option>)}
|
||||
</select>
|
||||
<button className="rounded-lg bg-aqua px-4 py-2 text-sm font-black text-ink" type="submit">Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
{status && <p className="mt-2 text-xs text-slate-400">{status}</p>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function AuthModal({ roster, login }) {
|
||||
const [name, setName] = useState('')
|
||||
const [pin, setPin] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const submit = async (event) => {
|
||||
event.preventDefault()
|
||||
try {
|
||||
await login(name, pin)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 grid place-items-center bg-black/80 p-4 backdrop-blur">
|
||||
<form onSubmit={submit} className="w-full max-w-sm rounded-2xl border border-line bg-panel p-6 text-center shadow-2xl">
|
||||
<h2 className="text-xl font-black text-aqua">Guest Access</h2>
|
||||
<p className="mt-2 text-sm text-slate-400">Select your name and enter the last 4 digits of your phone number.</p>
|
||||
<select className="mt-5 w-full rounded-lg border border-line bg-panel2 px-3 py-3 text-left outline-none focus:border-aqua" value={name} onChange={(event) => setName(event.target.value)}>
|
||||
<option value="">Select your name</option>
|
||||
{roster.map((guest) => <option key={guest.name} value={guest.name}>{guest.name}</option>)}
|
||||
</select>
|
||||
<input className="mt-3 w-full rounded-lg border border-line bg-panel2 px-3 py-3 text-center text-lg tracking-[0.3em] outline-none focus:border-aqua" inputMode="numeric" maxLength={4} type="password" value={pin} onChange={(event) => setPin(event.target.value.replace(/\D/g, '').slice(0, 4))} placeholder="0000" />
|
||||
{error && <p className="mt-3 text-sm text-red-300">{error}</p>}
|
||||
<button className="mt-5 w-full rounded-lg bg-aqua px-4 py-3 font-black text-ink" type="submit">Join the Vote</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { state, send } = useCaboData()
|
||||
const { guest, token, ready, login, logout } = useGuestSession()
|
||||
|
||||
if (!ready) return <div className="grid min-h-screen place-items-center bg-ink text-aqua">Loading...</div>
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppShell data={state} guest={guest} token={token} send={send} logout={logout} />
|
||||
{!guest && <AuthModal roster={state.guestRoster} login={login} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
59
client/src/index.css
Normal file
59
client/src/index.css
Normal file
@@ -0,0 +1,59 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
background: #0b0d14;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.leaflet-container {
|
||||
background: #0a0a14;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: #13161f;
|
||||
color: #e0e6f0;
|
||||
border: 1px solid #252a38;
|
||||
}
|
||||
|
||||
.cabo-popup {
|
||||
min-width: 190px;
|
||||
}
|
||||
|
||||
.cabo-popup strong {
|
||||
display: block;
|
||||
color: #00d4ff;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.cabo-popup p {
|
||||
margin: 0 0 8px;
|
||||
color: #9aa6bd;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.cabo-popup a,
|
||||
.cabo-popup button {
|
||||
display: inline-flex;
|
||||
margin-right: 6px;
|
||||
margin-top: 6px;
|
||||
border: 1px solid rgba(0, 212, 255, 0.35);
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 212, 255, 0.12);
|
||||
color: #bdf4ff;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
padding: 6px 9px;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
13
client/src/main.jsx
Normal file
13
client/src/main.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
21
client/vite.config.js
Normal file
21
client/vite.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
root: 'client',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:3001',
|
||||
'/ws': {
|
||||
target: 'ws://127.0.0.1:3001',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
2025
package-lock.json
generated
2025
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -5,6 +5,8 @@
|
||||
"description": "Real-time Cabo bachelor party voting with budgets, packages, and activity planning.",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"dev": "vite --config client/vite.config.js --host 0.0.0.0",
|
||||
"build": "vite build --config client/vite.config.js",
|
||||
"start": "node server.js"
|
||||
},
|
||||
"engines": {
|
||||
@@ -13,7 +15,18 @@
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.17.0",
|
||||
"uuid": "^11.1.0",
|
||||
"ws": "^8.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,14 @@
|
||||
<style>
|
||||
/* ── Map tab ─────────────────────────────────────────────── */
|
||||
#map-view {
|
||||
position: relative;
|
||||
height: calc(100vh - 120px);
|
||||
min-height: 400px;
|
||||
position: sticky;
|
||||
top: 86px;
|
||||
height: calc(100vh - 118px);
|
||||
min-height: 560px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: #0a0a14;
|
||||
}
|
||||
#cabo-map {
|
||||
position: absolute;
|
||||
@@ -598,7 +603,21 @@
|
||||
.tab.active .tab-count { background: rgba(0,212,255,0.15); color: var(--accent); }
|
||||
|
||||
/* ── Main content ──────────────────────────────────────── */
|
||||
main { flex: 1; overflow-y: auto; padding: 16px; max-width: 700px; margin: 0 auto; width: 100%; }
|
||||
main { flex: 1; overflow-y: auto; padding: 16px; max-width: 1440px; margin: 0 auto; width: 100%; }
|
||||
|
||||
.content-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(360px, 42vw);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
.list-pane,
|
||||
.map-pane {
|
||||
min-width: 0;
|
||||
}
|
||||
.mobile-view-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Status bar ─────────────────────────────────────────── */
|
||||
.status-bar {
|
||||
@@ -659,6 +678,32 @@
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.option-media {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(112px, 34%) 1fr;
|
||||
gap: 14px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.option-image-wrap {
|
||||
min-height: 158px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(0,212,255,0.16), rgba(251,191,36,0.10)),
|
||||
var(--surface2);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
.option-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 158px;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
filter: saturate(1.05) contrast(1.02);
|
||||
}
|
||||
.option-content {
|
||||
min-width: 0;
|
||||
}
|
||||
.option-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -667,6 +712,25 @@
|
||||
margin: 10px 0 2px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.option-book-btn {
|
||||
border: 1px solid rgba(251,191,36,0.38);
|
||||
background: rgba(251,191,36,0.12);
|
||||
color: #ffefbf;
|
||||
border-radius: 999px;
|
||||
padding: 7px 12px;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.2px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: transform 0.15s, border-color 0.2s, background 0.2s;
|
||||
}
|
||||
.option-book-btn:hover {
|
||||
border-color: rgba(251,191,36,0.62);
|
||||
background: rgba(251,191,36,0.18);
|
||||
transform: translateY(-1px);
|
||||
text-decoration: none;
|
||||
}
|
||||
.option-vote-btn {
|
||||
border: 1px solid rgba(0,212,255,0.32);
|
||||
background: rgba(0,212,255,0.10);
|
||||
@@ -1451,9 +1515,54 @@
|
||||
|
||||
/* Main adjusts for bottom tabs */
|
||||
main { padding: 12px; max-width: 100%; }
|
||||
.content-shell {
|
||||
display: block;
|
||||
}
|
||||
.mobile-view-toggle {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: rgba(19,22,31,0.92);
|
||||
}
|
||||
.mobile-view-toggle button {
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border-radius: 9px;
|
||||
padding: 9px 10px;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mobile-view-toggle button.active {
|
||||
background: rgba(0,212,255,0.12);
|
||||
border-color: rgba(0,212,255,0.35);
|
||||
color: var(--accent);
|
||||
}
|
||||
body.mobile-map-active .list-pane,
|
||||
body:not(.mobile-map-active) .map-pane {
|
||||
display: none;
|
||||
}
|
||||
#map-view {
|
||||
position: relative;
|
||||
top: auto;
|
||||
height: calc(100vh - 172px);
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
/* Option cards — larger tap targets */
|
||||
.option-card { min-height: 72px; padding: 14px 16px; }
|
||||
.option-media {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.option-image-wrap,
|
||||
.option-image {
|
||||
min-height: 178px;
|
||||
}
|
||||
.option-name { font-size: 0.92rem; }
|
||||
.option-desc { font-size: 0.76rem; }
|
||||
.option-actions { justify-content: flex-start; }
|
||||
@@ -1567,99 +1676,110 @@
|
||||
|
||||
<!-- Main -->
|
||||
<main>
|
||||
<div class="sort-bar" id="sortBar">
|
||||
<label for="sortModeSelect">Sort by</label>
|
||||
<select id="sortModeSelect" class="sort-select" onchange="setSortMode(this.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>
|
||||
<div class="mobile-view-toggle" id="mobileViewToggle" aria-label="Mobile view toggle">
|
||||
<button type="button" id="mobileListBtn" class="active" onclick="setMobileView('list')">List</button>
|
||||
<button type="button" id="mobileMapBtn" onclick="setMobileView('map')">Map</button>
|
||||
</div>
|
||||
|
||||
<div class="status-bar">
|
||||
<div><span class="status-dot" id="wsDot"></span><span id="wsStatus">Connecting…</span></div>
|
||||
<div><span class="polls-badge open" id="pollsBadge">POLLS OPEN</span></div>
|
||||
<div><span id="totalVotersCount"></span></div>
|
||||
</div>
|
||||
|
||||
<div class="options-list" id="optionsList" role="tabpanel" aria-label="Voting options" aria-live="polite">
|
||||
<div class="empty-state"><div class="empty-emoji">⏳</div>Loading options…</div>
|
||||
</div>
|
||||
|
||||
<!-- Map view -->
|
||||
<div id="map-view" style="display:none;">
|
||||
<div id="cabo-map"></div>
|
||||
<div class="map-overlay">
|
||||
<!-- Row 1: Multi-provider search -->
|
||||
<div id="map-search-wrap">
|
||||
<span style="color:#7a8499;font-size:0.8rem;padding-left:8px;flex-shrink:0;">🔍</span>
|
||||
<input type="text" id="map-search-input" placeholder="Search Los Cabos…" autocomplete="off" />
|
||||
<select id="flight-origin-select" aria-label="Flight origin airport">
|
||||
<option value="LAX">LAX</option>
|
||||
<option value="ONT">ONT</option>
|
||||
<div class="content-shell">
|
||||
<section class="list-pane" id="listPane">
|
||||
<div class="sort-bar" id="sortBar">
|
||||
<label for="sortModeSelect">Sort by</label>
|
||||
<select id="sortModeSelect" class="sort-select" onchange="setSortMode(this.value)">
|
||||
<option value="vote-desc">Votes: High to Low</option>
|
||||
<option value="vote-asc">Votes: Low to High</option>
|
||||
<option value="price-asc">Price: Low to High</option>
|
||||
<option value="price-desc">Price: High to Low</option>
|
||||
</select>
|
||||
<div class="provider-tabs">
|
||||
<button class="provider-tab active-yelp" id="tab-yelp" onclick="setProvider('yelp')">🍴 Yelp</button>
|
||||
<button class="provider-tab" id="tab-osm" onclick="setProvider('osm')">📍 OSM</button>
|
||||
<button class="provider-tab" id="tab-all" onclick="setProvider('all')">⚡ All</button>
|
||||
</div>
|
||||
|
||||
<div class="status-bar">
|
||||
<div><span class="status-dot" id="wsDot"></span><span id="wsStatus">Connecting…</span></div>
|
||||
<div><span class="polls-badge open" id="pollsBadge">POLLS OPEN</span></div>
|
||||
<div><span id="totalVotersCount"></span></div>
|
||||
</div>
|
||||
|
||||
<div class="options-list" id="optionsList" role="tabpanel" aria-label="Voting options" aria-live="polite">
|
||||
<div class="empty-state"><div class="empty-emoji">⏳</div>Loading options…</div>
|
||||
</div>
|
||||
|
||||
<!-- Add option -->
|
||||
<div class="add-section">
|
||||
<h3>➕ Suggest a Place</h3>
|
||||
<div class="form-grid">
|
||||
<input type="text" id="addName" placeholder="Name of the place (required)" maxlength="80" />
|
||||
<input type="text" id="addDesc" placeholder="Short description — price, vibe, what to expect…" maxlength="200" />
|
||||
<textarea id="addDetails" placeholder="Details on separate lines — price, inclusions, caveats, or notes…" maxlength="500" rows="3" style="background:transparent;border:1px solid #252a38;border-radius:10px;color:#e0e6f0;padding:10px;resize:vertical;min-height:84px;"></textarea>
|
||||
<input type="url" id="addUrl" placeholder="Website URL (optional)" />
|
||||
<div class="btn-row">
|
||||
<select id="addCategory">
|
||||
<option value="hotel">🏨 Hotel</option>
|
||||
<option value="flight">✈️ Flight</option>
|
||||
<option value="golf">⛳ Golf</option>
|
||||
<option value="nightlife">🎧 Nightlife</option>
|
||||
<option value="excursion">🚤 Excursion</option>
|
||||
<option value="itinerary">🗺️ Full Itinerary</option>
|
||||
<option value="budget">💸 Budget Idea</option>
|
||||
</select>
|
||||
<button class="btn-primary" id="addBtn" onclick="submitNewOption()">Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
<button id="map-search-btn" onclick="mapDoSearch()">→</button>
|
||||
</div>
|
||||
<div class="flight-shortcuts" aria-label="Flight search shortcuts">
|
||||
<button class="flight-shortcut-btn primary" onclick="quickBook('flights-google')">Google Flights</button>
|
||||
<button class="flight-shortcut-btn" onclick="quickBook('flights-kayak')">KAYAK</button>
|
||||
<button class="flight-shortcut-btn" onclick="quickBook('flights-united')">United</button>
|
||||
<button class="flight-shortcut-btn" onclick="quickBook('flights-delta')">Delta</button>
|
||||
<button class="flight-shortcut-btn" onclick="quickBook('flights-alaska')">Alaska</button>
|
||||
<button class="flight-shortcut-btn" onclick="quickBook('flights-expedia')">Expedia</button>
|
||||
</div>
|
||||
<div id="map-search-results"></div>
|
||||
<!-- Row 2: Category filters -->
|
||||
<div class="map-filter-row">
|
||||
<span class="row-label">Show</span>
|
||||
<button class="map-cat-btn active" id="cat-btn-hotel" onclick="mapToggleCat('hotel')">🏨 Hotels</button>
|
||||
<button class="map-cat-btn active" id="cat-btn-golf" onclick="mapToggleCat('golf')">⛳ Golf</button>
|
||||
<button class="map-cat-btn active" id="cat-btn-nightlife" onclick="mapToggleCat('nightlife')">🎧 Nightlife</button>
|
||||
<button class="map-cat-btn active" id="cat-btn-excursion" onclick="mapToggleCat('excursion')">🚤 Excursions</button>
|
||||
<button class="map-cat-btn active" id="cat-btn-itinerary" onclick="mapToggleCat('itinerary')">🗺️ Itineraries</button>
|
||||
<button class="map-cat-btn" id="cat-btn-clear" onclick="mapClearAllCats()" style="margin-left:4px;border-color:#f87171;color:#f87171;">✕ Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="map-legend">
|
||||
<h4>Legend</h4>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#3b82f6"></div> Hotel</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#22c55e"></div> Golf</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#06b6d4"></div> Excursion</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#a855f7"></div> Nightlife</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#fbbf24"></div> Itinerary</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#ff6b35;opacity:0.8"></div> Yelp</div>
|
||||
<div class="legend-item"><div class="legend-dot legend-dot-osm"></div> OSM</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:conic-gradient(#ff6b35 33%, #fbbf24 33%, #fbbf24 66%, #00d4ff 66%);width:9px;height:9px;border-radius:50%;display:inline-block;border:1px solid rgba(0,0,0,0.3)"></div> All sources</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Add option -->
|
||||
<div class="add-section">
|
||||
<h3>➕ Suggest a Place</h3>
|
||||
<div class="form-grid">
|
||||
<input type="text" id="addName" placeholder="Name of the place (required)" maxlength="80" />
|
||||
<input type="text" id="addDesc" placeholder="Short description — price, vibe, what to expect…" maxlength="200" />
|
||||
<textarea id="addDetails" placeholder="Details on separate lines — price, inclusions, caveats, or notes…" maxlength="500" rows="3" style="background:transparent;border:1px solid #252a38;border-radius:10px;color:#e0e6f0;padding:10px;resize:vertical;min-height:84px;"></textarea>
|
||||
<input type="url" id="addUrl" placeholder="Website URL (optional)" />
|
||||
<div class="btn-row">
|
||||
<select id="addCategory">
|
||||
<option value="hotel">🏨 Hotel</option>
|
||||
<option value="flight">✈️ Flight</option>
|
||||
<option value="golf">⛳ Golf</option>
|
||||
<option value="nightlife">🎧 Nightlife</option>
|
||||
<option value="excursion">🚤 Excursion</option>
|
||||
<option value="itinerary">🗺️ Full Itinerary</option>
|
||||
<option value="budget">💸 Budget Idea</option>
|
||||
</select>
|
||||
<button class="btn-primary" id="addBtn" onclick="submitNewOption()">Submit</button>
|
||||
<aside class="map-pane" id="mapPane" aria-label="Map view">
|
||||
<!-- Map view -->
|
||||
<div id="map-view">
|
||||
<div id="cabo-map"></div>
|
||||
<div class="map-overlay">
|
||||
<!-- Row 1: Multi-provider search -->
|
||||
<div id="map-search-wrap">
|
||||
<span style="color:#7a8499;font-size:0.8rem;padding-left:8px;flex-shrink:0;">🔍</span>
|
||||
<input type="text" id="map-search-input" placeholder="Search Los Cabos…" autocomplete="off" />
|
||||
<select id="flight-origin-select" aria-label="Flight origin airport">
|
||||
<option value="LAX">LAX</option>
|
||||
<option value="ONT">ONT</option>
|
||||
</select>
|
||||
<div class="provider-tabs">
|
||||
<button class="provider-tab active-yelp" id="tab-yelp" onclick="setProvider('yelp')">🍴 Yelp</button>
|
||||
<button class="provider-tab" id="tab-osm" onclick="setProvider('osm')">📍 OSM</button>
|
||||
<button class="provider-tab" id="tab-all" onclick="setProvider('all')">⚡ All</button>
|
||||
</div>
|
||||
<button id="map-search-btn" onclick="mapDoSearch()">→</button>
|
||||
</div>
|
||||
<div class="flight-shortcuts" aria-label="Flight search shortcuts">
|
||||
<button class="flight-shortcut-btn primary" onclick="quickBook('flights-google')">Google Flights</button>
|
||||
<button class="flight-shortcut-btn" onclick="quickBook('flights-kayak')">KAYAK</button>
|
||||
<button class="flight-shortcut-btn" onclick="quickBook('flights-united')">United</button>
|
||||
<button class="flight-shortcut-btn" onclick="quickBook('flights-delta')">Delta</button>
|
||||
<button class="flight-shortcut-btn" onclick="quickBook('flights-alaska')">Alaska</button>
|
||||
<button class="flight-shortcut-btn" onclick="quickBook('flights-expedia')">Expedia</button>
|
||||
</div>
|
||||
<div id="map-search-results"></div>
|
||||
<!-- Row 2: Category filters -->
|
||||
<div class="map-filter-row">
|
||||
<span class="row-label">Show</span>
|
||||
<button class="map-cat-btn active" id="cat-btn-hotel" onclick="mapToggleCat('hotel')">🏨 Hotels</button>
|
||||
<button class="map-cat-btn active" id="cat-btn-golf" onclick="mapToggleCat('golf')">⛳ Golf</button>
|
||||
<button class="map-cat-btn active" id="cat-btn-nightlife" onclick="mapToggleCat('nightlife')">🎧 Nightlife</button>
|
||||
<button class="map-cat-btn active" id="cat-btn-excursion" onclick="mapToggleCat('excursion')">🚤 Excursions</button>
|
||||
<button class="map-cat-btn active" id="cat-btn-itinerary" onclick="mapToggleCat('itinerary')">🗺️ Itineraries</button>
|
||||
<button class="map-cat-btn" id="cat-btn-clear" onclick="mapClearAllCats()" style="margin-left:4px;border-color:#f87171;color:#f87171;">✕ Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="map-legend">
|
||||
<h4>Legend</h4>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#3b82f6"></div> Hotel</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#22c55e"></div> Golf</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#06b6d4"></div> Excursion</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#a855f7"></div> Nightlife</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#fbbf24"></div> Itinerary</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#ff6b35;opacity:0.8"></div> Yelp</div>
|
||||
<div class="legend-item"><div class="legend-dot legend-dot-osm"></div> OSM</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:conic-gradient(#ff6b35 33%, #fbbf24 33%, #fbbf24 66%, #00d4ff 66%);width:9px;height:9px;border-radius:50%;display:inline-block;border:1px solid rgba(0,0,0,0.3)"></div> All sources</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
23
seed-data.js
23
seed-data.js
@@ -176,6 +176,25 @@ const BUDGET_SCENARIOS = [
|
||||
},
|
||||
];
|
||||
|
||||
const OPTION_IMAGE_QUERIES = {
|
||||
hotel: 'cabo san lucas resort',
|
||||
flight: 'airplane mexico coast',
|
||||
golf: 'los cabos golf course',
|
||||
nightlife: 'cabo san lucas nightlife',
|
||||
excursion: 'cabo san lucas boat',
|
||||
itinerary: 'los cabos beach marina',
|
||||
budget: 'cabo san lucas marina',
|
||||
};
|
||||
|
||||
function buildOptionImageUrl(option) {
|
||||
if (option.imageUrl) return option.imageUrl;
|
||||
const query = [
|
||||
option.name,
|
||||
OPTION_IMAGE_QUERIES[option.categoryId] || 'los cabos mexico',
|
||||
].filter(Boolean).join(' ');
|
||||
return `https://source.unsplash.com/640x420/?${encodeURIComponent(query)}`;
|
||||
}
|
||||
|
||||
function createOption(option) {
|
||||
const categoryColor = CATEGORY_META[option.categoryId]?.color || '#888';
|
||||
const primaryUrl = option.links?.[0]?.url || option.url || null;
|
||||
@@ -187,9 +206,13 @@ function createOption(option) {
|
||||
links: [],
|
||||
categoryColor,
|
||||
url: primaryUrl,
|
||||
bookingUrl: option.bookingUrl || primaryUrl,
|
||||
imageUrl: buildOptionImageUrl(option),
|
||||
...option,
|
||||
categoryColor,
|
||||
url: primaryUrl,
|
||||
bookingUrl: option.bookingUrl || primaryUrl,
|
||||
imageUrl: buildOptionImageUrl(option),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
20
server.js
20
server.js
@@ -12,6 +12,9 @@ const app = express();
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
const PUBLIC_DIR = path.join(__dirname, 'public');
|
||||
const CLIENT_DIST_DIR = path.join(__dirname, 'client', 'dist');
|
||||
const CLIENT_INDEX_FILE = path.join(CLIENT_DIST_DIR, 'index.html');
|
||||
const DEFAULT_DATA_DIR = path.join(__dirname, 'data');
|
||||
const DATA_DIR = process.env.DATA_DIR
|
||||
? path.resolve(process.env.DATA_DIR)
|
||||
@@ -30,10 +33,13 @@ app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
app.get('/admin', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'public', 'admin.html'));
|
||||
res.sendFile(path.join(PUBLIC_DIR, 'admin.html'));
|
||||
});
|
||||
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
if (fs.existsSync(CLIENT_INDEX_FILE)) {
|
||||
app.use(express.static(CLIENT_DIST_DIR));
|
||||
}
|
||||
app.use(express.static(PUBLIC_DIR));
|
||||
|
||||
function loadData() {
|
||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
@@ -1021,6 +1027,16 @@ app.get('/api/yelp', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('*', (req, res, next) => {
|
||||
if (req.path.startsWith('/api/') || req.path === '/admin') {
|
||||
return next();
|
||||
}
|
||||
if (!fs.existsSync(CLIENT_INDEX_FILE)) {
|
||||
return next();
|
||||
}
|
||||
return res.sendFile(CLIENT_INDEX_FILE);
|
||||
});
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
ws.send(JSON.stringify(buildRealtimeSnapshot()));
|
||||
|
||||
|
||||
20
tailwind.config.js
Normal file
20
tailwind.config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./client/index.html', './client/src/**/*.{js,jsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
ink: '#0b0d14',
|
||||
panel: '#13161f',
|
||||
panel2: '#1a1e2a',
|
||||
line: '#252a38',
|
||||
aqua: '#00d4ff',
|
||||
gold: '#fbbf24',
|
||||
},
|
||||
boxShadow: {
|
||||
glow: '0 18px 60px rgba(0, 212, 255, 0.14)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
Reference in New Issue
Block a user