feat: add BudgetTab with live vote-driven cost calculation
- BudgetTab shows current leaders per category (hotel/golf/nightlife/excursion) - Dominant tier (budget/balanced/splurge) auto-detected from votes - Per-person and group totals for 8/10/12 guy scenarios - Built on seed data pricing signals for accuracy
This commit is contained in:
243
client/src/components/BudgetTab.jsx
Normal file
243
client/src/components/BudgetTab.jsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { useMemo } from 'react'
|
||||
import './BudgetTab.css'
|
||||
|
||||
// Map options to cost tiers based on seed data pricing signals
|
||||
const TIER_BY_OPTION = {
|
||||
// Hotels
|
||||
'hotel-corazon': 'budget',
|
||||
'hotel-grand-fiesta': 'balanced',
|
||||
'hotel-pacifica': 'splurge',
|
||||
'hotel-breathless': 'splurge',
|
||||
'hotel-secrets': 'splurge',
|
||||
// Golf
|
||||
'golf-palmilla': 'budget',
|
||||
'golf-cabo-del-sol': 'balanced',
|
||||
'golf-quivira': 'splurge',
|
||||
'golf-puerto-los-cabos': 'balanced',
|
||||
// Nightlife
|
||||
'nightlife-mango-deck': 'budget',
|
||||
'nightlife-cabo-bash': 'balanced',
|
||||
'nightlife-cabo-agency': 'balanced',
|
||||
'nightlife-taboo': 'splurge',
|
||||
// Excursions
|
||||
'excursion-whale-public': 'budget',
|
||||
'excursion-atv': 'balanced',
|
||||
'excursion-sail': 'balanced',
|
||||
'excursion-whale-private': 'splurge',
|
||||
}
|
||||
|
||||
// Cost per person by category and tier (from seed data planning numbers)
|
||||
const COST_PER_PERSON = {
|
||||
hotel: { budget: 450, balanced: 850, splurge: 1250 },
|
||||
golf: { budget: 130, balanced: 180, splurge: 250 },
|
||||
nightlife: { budget: 40, balanced: 100, splurge: 200 },
|
||||
excursion: { budget: 76, balanced: 110, splurge: 188 },
|
||||
// Fixed costs
|
||||
flight: 350,
|
||||
transfers: { budget: 33, balanced: 83, splurge: 183 },
|
||||
foodBuffer: 275,
|
||||
}
|
||||
|
||||
const GROUP_SIZES = [8, 10, 12]
|
||||
|
||||
const CATEGORY_LABELS = {
|
||||
hotel: '🏨 Hotel',
|
||||
golf: '⛳ Golf',
|
||||
nightlife: '🎧 Nightlife',
|
||||
excursion: '🚤 Excursion',
|
||||
}
|
||||
|
||||
function getOptionTier(optionId) {
|
||||
return TIER_BY_OPTION[optionId] || 'balanced'
|
||||
}
|
||||
|
||||
function calcTierTotal(tier, groupSize) {
|
||||
const hotel = COST_PER_PERSON.hotel[tier]
|
||||
const golf = COST_PER_PERSON.golf[tier]
|
||||
const nightlife = COST_PER_PERSON.nightlife[tier]
|
||||
const excursion = COST_PER_PERSON.excursion[tier]
|
||||
const flight = COST_PER_PERSON.flight
|
||||
const transfer = COST_PER_PERSON.transfers[tier]
|
||||
const food = COST_PER_PERSON.foodBuffer
|
||||
|
||||
const total = flight + hotel + golf + nightlife + excursion + transfer + food
|
||||
const perPerson = Math.round(total * 100) / 100
|
||||
return { total: perPerson * groupSize, perPerson }
|
||||
}
|
||||
|
||||
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 getTierFromCategoryVotes(options, categoryId) {
|
||||
const winning = getWinningOption(options, categoryId)
|
||||
if (!winning) return 'balanced'
|
||||
return getOptionTier(winning.id)
|
||||
}
|
||||
|
||||
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'
|
||||
// Find the most common tier
|
||||
return Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0] || 'balanced'
|
||||
}
|
||||
|
||||
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])
|
||||
|
||||
const tiers = useMemo(() => {
|
||||
const t = {}
|
||||
votingCategories.forEach(cat => {
|
||||
t[cat] = getTierFromCategoryVotes(options, cat)
|
||||
})
|
||||
return t
|
||||
}, [options])
|
||||
|
||||
const overallTier = useMemo(() => dominantTier(tiers), [tiers])
|
||||
|
||||
const totals = useMemo(() => {
|
||||
return GROUP_SIZES.map(size => {
|
||||
const tier = overallTier
|
||||
const { perPerson, total } = calcTierTotal(tier, size)
|
||||
return { size, tier, perPerson, total }
|
||||
})
|
||||
}, [overallTier])
|
||||
|
||||
const tierLabel = { budget: '💰 Budget', balanced: '⚖️ Balanced', splurge: '💎 Splurge' }
|
||||
const tierColor = { budget: '#22c55e', balanced: '#3b82f6', splurge: '#f97316' }
|
||||
|
||||
const hasAnyVotes = Object.values(leaders).some(o => o && o.votes?.length > 0)
|
||||
|
||||
return (
|
||||
<div className="budget-tab">
|
||||
<div className="budget-header">
|
||||
<h2>Trip Budget Calculator</h2>
|
||||
<p>Costs update live based on leading votes in each category</p>
|
||||
</div>
|
||||
|
||||
{/* Category leaders */}
|
||||
<div className="budget-leaders">
|
||||
<h3>Current Leaders by Category</h3>
|
||||
<div className="leaders-grid">
|
||||
{votingCategories.map(cat => {
|
||||
const leader = leaders[cat]
|
||||
const catTier = tiers[cat]
|
||||
return (
|
||||
<div key={cat} className="leader-card" style={{ '--cat-color': CATEGORY_LABELS[cat].split(' ')[0] }}>
|
||||
<div className="leader-cat">{CATEGORY_LABELS[cat]}</div>
|
||||
{leader && leader.votes?.length > 0 ? (
|
||||
<>
|
||||
<div className="leader-name">{leader.name}</div>
|
||||
<div className="leader-tier" style={{ color: tierColor[catTier] }}>
|
||||
{tierLabel[catTier]}
|
||||
</div>
|
||||
<div className="leader-votes">{leader.votes.length} vote{leader.votes.length !== 1 ? 's' : ''}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="leader-name no-votes">No votes yet</div>
|
||||
<div className="leader-tier" style={{ color: '#888' }}>—</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tier selector — highlight dominant */}
|
||||
<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>
|
||||
|
||||
{/* Cost breakdown */}
|
||||
{hasAnyVotes ? (
|
||||
<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>
|
||||
{GROUP_SIZES.map(s => <span key={s}>{s} guys</span>)}
|
||||
</div>
|
||||
<div className="breakdown-row">
|
||||
<span>✈️ Flight estimate</span>
|
||||
{GROUP_SIZES.map(s => <span key={s}>${(COST_PER_PERSON.flight).toLocaleString()}</span>)}
|
||||
</div>
|
||||
<div className="breakdown-row">
|
||||
<span>🏨 Hotel ({COST_PER_PERSON.hotel[overallTier]}/pp)</span>
|
||||
{GROUP_SIZES.map(s => <span key={s}>${(COST_PER_PERSON.hotel[overallTier]).toLocaleString()}</span>)}
|
||||
</div>
|
||||
<div className="breakdown-row">
|
||||
<span>⛳ Golf ({COST_PER_PERSON.golf[overallTier]}/pp)</span>
|
||||
{GROUP_SIZES.map(s => <span key={s}>${(COST_PER_PERSON.golf[overallTier]).toLocaleString()}</span>)}
|
||||
</div>
|
||||
<div className="breakdown-row">
|
||||
<span>🎧 Nightlife ({COST_PER_PERSON.nightlife[overallTier]}/pp)</span>
|
||||
{GROUP_SIZES.map(s => <span key={s}>${(COST_PER_PERSON.nightlife[overallTier]).toLocaleString()}</span>)}
|
||||
</div>
|
||||
<div className="breakdown-row">
|
||||
<span>🚤 Excursion ({COST_PER_PERSON.excursion[overallTier]}/pp)</span>
|
||||
{GROUP_SIZES.map(s => <span key={s}>${(COST_PER_PERSON.excursion[overallTier]).toLocaleString()}</span>)}
|
||||
</div>
|
||||
<div className="breakdown-row">
|
||||
<span>🚗 Transfers</span>
|
||||
{GROUP_SIZES.map(s => <span key={s}>${(COST_PER_PERSON.transfers[overallTier]).toLocaleString()}</span>)}
|
||||
</div>
|
||||
<div className="breakdown-row">
|
||||
<span>🍽️ Food + drinks buffer</span>
|
||||
{GROUP_SIZES.map(s => <span key={s}>$${COST_PER_PERSON.foodBuffer}</span>)}
|
||||
</div>
|
||||
<div className="breakdown-row total-row">
|
||||
<span>Est. Total / Person</span>
|
||||
{totals.map(t => (
|
||||
<span key={t.size} style={{ color: tierColor[overallTier], fontWeight: 700 }}>
|
||||
${t.perPerson.toLocaleString()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="breakdown-row group-row">
|
||||
<span>Est. Group Total</span>
|
||||
{totals.map(t => (
|
||||
<span key={t.size} style={{ color: tierColor[overallTier], fontWeight: 700 }}>
|
||||
${t.total.toLocaleString()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="budget-note">
|
||||
* Estimates based on current leading votes. Prices may vary. Add extra for bar tabs and tips.
|
||||
Price data from {options.find(o => o.categoryId === 'hotel')?.details?.[0]?.match(/\d{4}-\d{2}-\d{2}/)?.[0] || 'April 2026'}.
|
||||
</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.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user