feat: add cabo package planning and price watch assets
This commit is contained in:
@@ -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}!`);
|
||||
|
||||
Reference in New Issue
Block a user