feat: add cabo package planning and price watch assets

This commit is contained in:
TopherMayor
2026-04-29 21:29:20 -07:00
parent 88ee723981
commit 8f31b80647
8 changed files with 1951 additions and 312 deletions

View File

@@ -333,6 +333,7 @@
--nightlife: #a855f7;
--excursion: #06b6d4;
--itinerary: #fbbf24;
--budget: #f97316;
}
/* ── Reset ──────────────────────────────────────────────── */
@@ -596,6 +597,29 @@
margin-bottom: 8px;
}
.option-link:hover { opacity: 1; text-decoration: underline; }
.option-links {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.option-pill {
display: inline-flex;
align-items: center;
gap: 4px;
border: 1px solid rgba(0, 212, 255, 0.2);
background: rgba(0, 212, 255, 0.08);
border-radius: 999px;
padding: 4px 8px;
font-size: 0.66rem;
color: var(--accent);
opacity: 0.92;
}
.option-pill:hover {
opacity: 1;
text-decoration: none;
border-color: rgba(0, 212, 255, 0.45);
}
/* Vote bar */
.vote-bar-bg {
@@ -615,6 +639,7 @@
.vote-bar-fill.nightlife { background: var(--nightlife); }
.vote-bar-fill.excursion { background: var(--excursion); }
.vote-bar-fill.itinerary { background: var(--itinerary); }
.vote-bar-fill.budget { background: var(--budget); }
.voters-row {
margin-top: 5px;
@@ -637,6 +662,117 @@
font-size: 0.65rem;
color: var(--text-muted);
}
.budget-board {
margin-bottom: 16px;
background:
radial-gradient(circle at top right, rgba(249, 115, 22, 0.16), transparent 38%),
linear-gradient(135deg, rgba(251, 191, 36, 0.08), rgba(19, 22, 31, 0.95) 48%, rgba(6, 182, 212, 0.08));
border: 1px solid rgba(249, 115, 22, 0.28);
border-radius: 18px;
padding: 18px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.24);
}
.budget-board h2 {
font-size: 1.05rem;
color: #ffd7b0;
margin-bottom: 6px;
}
.budget-board p {
font-size: 0.78rem;
color: #e1c9b8;
line-height: 1.5;
}
.budget-stamp {
display: inline-flex;
margin-top: 10px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(249, 115, 22, 0.12);
color: #ffbe88;
font-size: 0.68rem;
letter-spacing: 0.3px;
}
.budget-grid {
margin: 16px 0 18px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 12px;
}
.budget-card {
background: rgba(11, 13, 20, 0.76);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: 14px;
backdrop-filter: blur(10px);
}
.budget-card h3 {
font-size: 0.95rem;
margin-bottom: 4px;
color: #fff3e8;
}
.budget-card .budget-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
color: #fbc08c;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.budget-card .budget-price {
font-size: 1.5rem;
font-weight: 800;
color: #fff;
margin-bottom: 4px;
}
.budget-card .budget-total {
font-size: 0.72rem;
color: #ffcf9c;
margin-bottom: 10px;
}
.budget-card .budget-summary {
font-size: 0.76rem;
color: #d9e3f4;
line-height: 1.45;
margin-bottom: 10px;
}
.budget-card ul {
list-style: none;
display: grid;
gap: 5px;
}
.budget-card li {
font-size: 0.7rem;
color: #b7c0d4;
line-height: 1.4;
padding-left: 10px;
position: relative;
}
.budget-card li::before {
content: '';
width: 4px;
height: 4px;
border-radius: 50%;
background: currentColor;
position: absolute;
left: 0;
top: 0.55em;
opacity: 0.8;
}
.budget-tier {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 999px;
font-weight: 700;
font-size: 0.66rem;
letter-spacing: 0.3px;
}
.budget-tier.budget { background: rgba(52, 211, 153, 0.12); color: #7df0c0; }
.budget-tier.balanced { background: rgba(6, 182, 212, 0.12); color: #7fe7ff; }
.budget-tier.splurge { background: rgba(249, 115, 22, 0.14); color: #ffbe88; }
/* ── Add Option ─────────────────────────────────────────── */
.add-section {
@@ -1059,6 +1195,7 @@
<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>
@@ -1082,6 +1219,8 @@
voterName: localStorage.getItem('cabo_voter_name') || '',
categories: [],
options: [],
budgetScenarios: [],
priceUpdatedAt: '',
pollsOpen: true,
totalVoters: 0,
wsConnected: false,
@@ -1163,6 +1302,8 @@
if (msg.type === 'init') {
state.categories = msg.categories;
state.options = msg.options;
state.budgetScenarios = msg.budgetScenarios || [];
state.priceUpdatedAt = msg.priceUpdatedAt || '';
state.pollsOpen = msg.pollsOpen;
state.totalVoters = msg.totalVoters;
renderTabs();
@@ -1170,7 +1311,7 @@
} else if (msg.type === 'vote_update') {
msg.results.forEach(r => {
const opt = state.options.find(o => o.id === r.id);
if (opt) { opt.votes = r.votes; opt.voters = r.voters; }
if (opt) { opt.votes = (r.voters || []).map(name => ({ name })); }
});
render();
if (mapInitialized) mapRefreshMarkers();
@@ -1227,6 +1368,12 @@
badge.className = 'polls-badge ' + (state.pollsOpen ? 'open' : 'closed');
}
function getVoteEntries(opt) {
if (Array.isArray(opt.votes)) return opt.votes;
if (Array.isArray(opt.voters)) return opt.voters.map(name => ({ name }));
return [];
}
// ── Name modal ────────────────────────────────────────────
function submitName() {
const name = document.getElementById('voterNameInput').value.trim();
@@ -1339,7 +1486,17 @@
const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : rank;
const medalClass = rank === 1 ? 'gold' : rank === 2 ? 'silver' : rank === 3 ? 'bronze' : '';
const winner = rank === 1 ? 'winner' : '';
const barColor = cat.id === 'hotel' ? 'var(--hotel)' : cat.id === 'golf' ? 'var(--golf)' : cat.id === 'nightlife' ? 'var(--nightlife)' : cat.id === 'excursion' ? 'var(--excursion)' : 'var(--itinerary)';
const barColor = cat.id === 'hotel'
? 'var(--hotel)'
: cat.id === 'golf'
? 'var(--golf)'
: cat.id === 'nightlife'
? 'var(--nightlife)'
: cat.id === 'excursion'
? 'var(--excursion)'
: cat.id === 'budget'
? 'var(--budget)'
: 'var(--itinerary)';
return `
<div class="results-row">
<div class="results-rank ${medalClass}">${medal}</div>
@@ -1369,23 +1526,30 @@
}
// Sort by votes desc
const sorted = [...opts].sort((a, b) => b.votes.length - a.votes.length);
const maxVotes = sorted[0] ? sorted[0].votes.length : 1;
const sorted = [...opts].sort((a, b) => getVoteEntries(b).length - getVoteEntries(a).length);
const maxVotes = sorted[0] ? getVoteEntries(sorted[0]).length : 1;
const budgetBoard = activeTab === 'budget' ? renderBudgetBoard() : '';
list.innerHTML = sorted.map(opt => {
list.innerHTML = budgetBoard + sorted.map(opt => {
const catClass = opt.categoryId;
const votePct = maxVotes > 0 ? (opt.votes.length / maxVotes * 100) : 0;
const hasVoted = state.voterName && opt.votes.some(v => v.name === state.voterName);
const voteList = opt.votes.map(v => v.name).join(', ');
const voteEntries = getVoteEntries(opt);
const votePct = maxVotes > 0 ? (voteEntries.length / maxVotes * 100) : 0;
const hasVoted = state.voterName && voteEntries.some(v => v.name === state.voterName);
const voteList = voteEntries.map(v => v.name).join(', ');
const linkPills = opt.links && opt.links.length
? `<div class="option-links">${opt.links.map(link => `
<a href="${link.url}" target="_blank" rel="noopener noreferrer" class="option-pill" onclick="event.stopPropagation()">${link.label}</a>
`).join('')}</div>`
: (opt.url ? `<a href="${opt.url}" target="_blank" rel="noopener noreferrer" class="option-link" onclick="event.stopPropagation()">🔗 ${opt.url.replace(/^https?:\/\//, '').split('/')[0]}</a>` : '');
return `
<div class="option-card${hasVoted ? ' voted' : ''}" onclick="toggleVote('${opt.id}')">
<div class="option-top">
<div class="option-name">${opt.name}</div>
<div class="option-votes">${opt.votes.length} vote${opt.votes.length !== 1 ? 's' : ''}</div>
<div class="option-votes">${voteEntries.length} vote${voteEntries.length !== 1 ? 's' : ''}</div>
</div>
${opt.desc ? `<div class="option-desc">${opt.desc}</div>` : ''}
${opt.url ? `<a href="${opt.url}" target="_blank" rel="noopener noreferrer" class="option-link" onclick="event.stopPropagation()">🔗 ${opt.url.replace(/^https?:\/\//, '').split('/')[0]}</a>` : ''}
${linkPills}
${opt.details && opt.details.length ? `
<div class="itin-details">${opt.details.map(d => `<span class="itin-tag">${d}</span>`).join('')}</div>
` : ''}
@@ -1398,6 +1562,39 @@
}).join('');
}
function renderBudgetBoard() {
if (!state.budgetScenarios.length) return '';
const tierOrder = { Budget: 0, Balanced: 1, Splurge: 2 };
const scenarios = [...state.budgetScenarios].sort((a, b) => {
if (a.groupSize !== b.groupSize) return a.groupSize - b.groupSize;
return (tierOrder[a.tier] ?? 99) - (tierOrder[b.tier] ?? 99);
});
return `
<section class="budget-board">
<h2>💸 Budget Cheat Sheet</h2>
<p>These are planning numbers for the group to compare tracks quickly before anyone starts buying flights. They use current live price signals and bake in the shared-cost difference between 8, 10, and 12 guys.</p>
<div class="budget-stamp">Pricing research last refreshed ${state.priceUpdatedAt || 'recently'}</div>
<div class="budget-grid">
${scenarios.map(scenario => `
<article class="budget-card">
<div class="budget-meta">
<span>${scenario.groupSize} guys</span>
<span class="budget-tier ${scenario.tier.toLowerCase()}">${scenario.tier}</span>
</div>
<h3>${scenario.tier} Track</h3>
<div class="budget-price">$${scenario.perPerson.toLocaleString()}</div>
<div class="budget-total">$${scenario.groupTotal.toLocaleString()} group total</div>
<div class="budget-summary">${scenario.summary}</div>
<ul>${scenario.notes.map(note => `<li>${note}</li>`).join('')}</ul>
</article>
`).join('')}
</div>
</section>
`;
}
// ── Voting ────────────────────────────────────────────────
function toggleVote(optionId) {
if (activeTab === 'results') return; // no voting on results tab
@@ -1414,7 +1611,7 @@
const opt = state.options.find(o => o.id === optionId);
if (!opt) return;
const alreadyVoted = opt.votes.some(v => v.name === state.voterName);
const alreadyVoted = getVoteEntries(opt).some(v => v.name === state.voterName);
wsSend({ type: 'vote', optionId, voterName: state.voterName, remove: alreadyVoted });
showToast(alreadyVoted ? `Removed vote for ${opt.name}` : `Voted for ${opt.name}!`);