Files
cabo-voting-app/client/src/components/BudgetTab.jsx

372 lines
15 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo } from 'react'
import './BudgetTab.css'
// ─── Price parsers ────────────────────────────────────────────────────────────
function extractPrices(details) {
if (!details || !details.length) return {}
const text = details.join(' ')
const prices = {}
// Per-person package: "Apple exact-date quote: $2,016 pp" / "Costco package: $1,678.99 pp"
const ppMatch = text.match(/\$([\d,]+(?:\.\d{2})?)\s*(?:pp|per person|per-person)/i)
if (ppMatch) prices.perPerson = parseFloat(ppMatch[1].replace(/,/g, ''))
// Per-night: "KAYAK from $212/night" / "KAYAK exact-date room rate: $335/night"
const pnMatch = text.match(/\$([\d,]+(?:\.\d{2})?)\s*(?:\/night|per night|per-night)/i)
if (pnMatch) prices.perNight = parseFloat(pnMatch[1].replace(/,/g, ''))
// Group-total with size: "Pool island for 4: $884" / "Gold package for 16 guests: $1,700"
// Group-total with size: "Pool island for 4: $884" / "Gold package for 16 guests: $1,700"
const groupMatch = text.match(/(?:for|at)\s+(\d+)[^\d$]*\$([\d,]+(?:\.\d{2})?)/i)
|| text.match(/\$([\d,]+(?:\.\d{2})?)\s+(?:total|for\s+\d+)/i)
if (groupMatch) {
prices.groupTotal = parseFloat((groupMatch[2] || groupMatch[1]).replace(/,/g, ''))
const sizeMatch = text.match(/for\s+(\d+)\s*[^\d$]*/i)
if (sizeMatch) prices.groupSize = parseInt(sizeMatch[1])
}
// Simple "From $1,504 total"
if (!prices.groupTotal) {
const totalMatch = text.match(/(?:from\s+)?\$([\d,]+(?:\.\d{2})?)\s+total/i)
if (totalMatch) prices.groupTotal = parseFloat(totalMatch[1].replace(/,/g, ''))
}
// Per-person from group total: "About $188 pp at 8"
const ppAtMatch = text.match(/\$\d+[\d,.]*\s*(?:pp|per person).*?at\s+(\d+)/i)
|| text.match(/about?\$([\d,]+(?:\.\d{2})?)\s*(?:pp|per person).*?at\s+(\d+)/i)
if (ppAtMatch) {
prices.perPerson = parseFloat(ppAtMatch[1].replace(/,/g, ''))
prices.atGroupSize = parseInt(ppAtMatch[2])
}
// Planning number: "Use about $180 as current planning number"
const planMatch = text.match(/use\s+(?:about\s+)?\$([\d,]+(?:\.\d{2})?)\s+(?:as\s+)?(?:the\s+)?(?:current\s+)?planning/i)
if (planMatch && !prices.perPerson) prices.planningRate = parseFloat(planMatch[1].replace(/,/g, ''))
// Flat per-person: "From $76" — only if no other signals
if (!prices.perPerson && !prices.perNight && !prices.groupTotal && !prices.planningRate) {
const flatMatch = text.match(/(?:from\s+)?\$([\d,]+(?:\.\d{2})?)\b(?!.*\b(?:pp|per|total|for\b)/i)
if (flatMatch) {
const val = parseFloat(flatMatch[1].replace(/,/g, ''))
if (val > 20) prices.perPerson = val
}
}
return prices
}
// ─── Check whether an option has real scraped pricing ─────────────────────────
function hasRealPricing(option) {
const p = extractPrices(option.details || [])
// Has at least one real price signal (not just a planning estimate or generic text)
return !!(p.perPerson || p.perNight || p.groupTotal || p.planningRate)
}
// ─── Resolve per-person cost for an option ───────────────────────────────────
function resolvePerPerson(option, fallback) {
const p = extractPrices(option.details || [])
if (p.perPerson) return p.perPerson
if (p.perNight) return p.perNight * 3 // assume 3-night stay
if (p.groupTotal && p.groupSize) return Math.round(p.groupTotal / p.groupSize)
if (p.groupTotal && p.atGroupSize) {
// scale to 8-person group as baseline
return Math.round(p.groupTotal / p.atGroupSize)
}
if (p.groupTotal) return Math.round(p.groupTotal / 8)
if (p.planningRate) return p.planningRate
return fallback
}
// ─── Fixed costs ─────────────────────────────────────────────────────────────
const FLIGHT_PP = 350
const FOOD_BUFFER = 275
// ─── Per-person costs from real scraped data (used when details are empty) ──
const FALLBACK_HOTEL = { budget: 450, balanced: 850, splurge: 1250 }
const FALLBACK_GOLF = { budget: 130, balanced: 180, splurge: 250 }
const FALLBACK_NIGHTLIFE = { budget: 40, balanced: 100, splurge: 200 }
const FALLBACK_EXCURSION = { budget: 76, balanced: 110, splurge: 188 }
const FALLBACK_TRANSFER = { budget: 33, balanced: 83, splurge: 183 }
// ─── Tier classification by hotel price signal ──────────────────────────────
function classifyTier(optionId, perPerson) {
// Use the actual scraped per-person package prices as ground truth
const KNOWN_PP = {
// Hotels — package prices
'hotel-corazon': null, // no live rate
'hotel-breathless': 1678.99, // Costco package
'hotel-grand-fiesta': null, // no Costco live, Apple $2,111
'hotel-secrets': 2005.80, // Costco package
'hotel-pacifica': null, // no live rate
'hotel-dreams': null,
// Hotels — nightly rates → scaled to 3-night
// KAYAK per-night rates × 3 nights
}
if (KNOWN_PP[optionId] !== undefined && KNOWN_PP[optionId] !== null) {
const pp = KNOWN_PP[optionId]
if (pp <= 1000) return 'budget'
if (pp <= 1900) return 'balanced'
return 'splurge'
}
// Nightly rate proxies (×3 nights)
if (perPerson <= 300) return 'budget'
if (perPerson <= 900) return 'balanced'
return 'splurge'
}
// ─── Category label / emoji ──────────────────────────────────────────────────
const CAT_LABELS = {
hotel: '🏨 Hotel',
golf: '⛳ Golf',
nightlife: '🎧 Nightlife',
excursion: '🚤 Excursion',
}
const GROUP_SIZES = [8, 10, 12]
// ─── Main component ─────────────────────────────────────────────────────────
function getWinningOption(options, categoryId) {
const catOpts = options.filter(o => o.categoryId === categoryId && o.approved)
if (!catOpts.length) return null
return catOpts.reduce((best, o) =>
(o.votes?.length || 0) > (best.votes?.length || 0) ? o : best
, catOpts[0])
}
function dominantTier(tiers) {
const counts = { budget: 0, balanced: 0, splurge: 0 }
Object.values(tiers).forEach(t => { if (t) counts[t] = (counts[t] || 0) + 1 })
if (counts.splurge >= 2) return 'splurge'
if (counts.budget >= 2) return 'budget'
return Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0] || 'balanced'
}
function formatPrice(v) {
if (!v) return '—'
return '$' + v.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })
}
function getPriceLabel(option) {
const p = extractPrices(option.details || [])
const text = (option.details || []).join(' ')
if (p.perPerson) {
// Prefer package price label
const pkgMatch = text.match(/(Costco|Apple).*?\$[\d,]+(?:\.\d{2})?\s*(?:pp|per person)/i)
if (pkgMatch) {
const m = text.match(/\$([\d,]+(?:\.\d{2})?)\s*(?:pp|per person)/i)
if (m) return `pkg ${formatPrice(parseFloat(m[1].replace(/,/g, '')))}/pp`
}
return formatPrice(p.perPerson) + '/pp'
}
if (p.perNight) {
const nights = 3
return formatPrice(p.perNight) + '/night → ' + formatPrice(p.perNight * nights) + ' for 3 nights'
}
if (p.groupTotal && p.groupSize) {
const pp = Math.round(p.groupTotal / p.groupSize)
return formatPrice(p.groupTotal) + ' total ÷ ' + p.groupSize + ' = ' + formatPrice(pp) + '/pp'
}
if (p.groupTotal) {
const pp = Math.round(p.groupTotal / 8)
return formatPrice(p.groupTotal) + ' group total (~' + formatPrice(pp) + '/pp)'
}
if (p.planningRate) return '~$' + Math.round(p.planningRate) + '/pp planning'
return null
}
export default function BudgetTab({ options, categories }) {
const votingCategories = ['hotel', 'golf', 'nightlife', 'excursion']
const leaders = useMemo(() => {
const result = {}
votingCategories.forEach(cat => {
result[cat] = getWinningOption(options, cat)
})
return result
}, [options])
// Only include leaders that have actual scraped pricing
const pricedLeaders = useMemo(() => {
const result = {}
votingCategories.forEach(cat => {
const leader = leaders[cat]
if (leader && hasRealPricing(leader)) result[cat] = leader
})
return result
}, [leaders])
// Determine tier per category based on actual prices
const tiers = useMemo(() => {
const t = {}
votingCategories.forEach(cat => {
const leader = pricedLeaders[cat]
if (!leader) { t[cat] = 'balanced'; return }
const pp = resolvePerPerson(leader, null)
t[cat] = classifyTier(leader.id, pp)
})
return t
}, [pricedLeaders])
const overallTier = useMemo(() => dominantTier(tiers), [tiers])
// Build the cost breakdown from real prices only
const costRows = useMemo(() => {
const rows = {}
votingCategories.forEach(cat => {
const leader = pricedLeaders[cat]
if (!leader) return // skip categories without priced leaders
const pp = resolvePerPerson(leader, null)
rows[cat] = { label: CAT_LABELS[cat], pp, source: leader.name }
})
// Transfer depends on tier (only show if we have at least a hotel)
if (Object.keys(pricedLeaders).length > 0) {
rows.transfer = { label: '🚗 Transfers', pp: FALLBACK_TRANSFER[overallTier], source: 'shared shuttle estimate' }
}
rows.flight = { label: '✈️ Flights', pp: FLIGHT_PP, source: 'group average estimate' }
rows.food = { label: '🍽️ Food + drinks', pp: FOOD_BUFFER, source: 'buffer for 3 days' }
return rows
}, [pricedLeaders, overallTier])
const totals = useMemo(() => {
return GROUP_SIZES.map(size => {
const sum = Object.values(costRows).reduce((acc, row) => acc + (row.pp || 0), 0)
return { size, perPerson: Math.round(sum), total: Math.round(sum * size) }
})
}, [costRows])
const hasPricedLeaders = Object.keys(pricedLeaders).length > 0
const tierColor = { budget: '#22c55e', balanced: '#3b82f6', splurge: '#f97316' }
const tierLabel = { budget: '💰 Budget', balanced: '⚖️ Balanced', splurge: '💎 Splurge' }
return (
<div className="budget-tab">
<div className="budget-header">
<h2>Trip Budget Calculator</h2>
<p>Prices sourced from live travel data updates as votes come in</p>
</div>
{/* Category leaders with real prices */}
<div className="budget-leaders">
<h3>Current Leaders Live Prices</h3>
{Object.keys(pricedLeaders).length > 0 ? (
<div className="leaders-grid">
{votingCategories.map(cat => {
const leader = pricedLeaders[cat]
if (!leader) return null
const catTier = tiers[cat]
const priceLabel = getPriceLabel(leader)
return (
<div key={cat} className="leader-card" style={{ '--cat-color': tierColor[catTier] }}>
<div className="leader-cat">{CAT_LABELS[cat]}</div>
<div className="leader-name">{leader.name}</div>
{priceLabel && <div className="leader-price">{priceLabel}</div>}
<div className="leader-tier" style={{ color: tierColor[catTier] }}>
{tierLabel[catTier]}
</div>
<div className="leader-votes">
{leader.votes?.length > 0
? `${leader.votes.length} vote${leader.votes.length !== 1 ? 's' : ''}`
: 'no votes yet'}
</div>
</div>
)
})}
</div>
) : (
<div className="budget-placeholder">
<div className="placeholder-icon"></div>
<p>Waiting for pricing data. Options without live prices from travel sites are hidden until the scraper updates them.</p>
</div>
)}
</div>
{/* Tier summary pills */}
<div className="budget-tiers">
{['budget', 'balanced', 'splurge'].map(tier => (
<div
key={tier}
className={`tier-card ${overallTier === tier ? 'active' : ''}`}
style={{ '--tier-color': tierColor[tier] }}
>
<div className="tier-label">{tierLabel[tier]}</div>
{overallTier === tier && <div className="tier-badge">Current</div>}
</div>
))}
</div>
{/* Dynamic cost breakdown */}
{hasPricedLeaders ? (
<div className="budget-breakdown">
<h3>Estimated Cost {tierLabel[overallTier]} Tier</h3>
<div className="breakdown-table">
<div className="breakdown-row header-row">
<span>Line Item</span>
<span>Source</span>
{GROUP_SIZES.map(s => <span key={s}>{s} guys</span>)}
</div>
{[
costRows.flight,
costRows.hotel,
costRows.golf,
costRows.nightlife,
costRows.excursion,
costRows.transfer,
costRows.food,
].map(row => row && (
<div className="breakdown-row" key={row.label}>
<span>{row.label}</span>
<span className="source-cell">{row.source || '—'}</span>
{GROUP_SIZES.map(s => (
<span key={s}>{formatPrice(row.pp)}</span>
))}
</div>
))}
<div className="breakdown-row total-row">
<span>Est. Per Person</span>
<span className="source-cell">sum</span>
{totals.map(t => (
<span key={t.size} style={{ color: tierColor[overallTier], fontWeight: 700 }}>
{formatPrice(t.perPerson)}
</span>
))}
</div>
<div className="breakdown-row group-row">
<span>Est. Group Total</span>
<span className="source-cell"></span>
{totals.map(t => (
<span key={t.size} style={{ color: tierColor[overallTier], fontWeight: 700 }}>
{formatPrice(t.total)}
</span>
))}
</div>
</div>
<p className="budget-note">
* Prices from live scraped data. Hotel rates are package or nightly × 3 nights.
Per-person costs shown are the same for all group sizes. Add bar tabs and tips separately.
</p>
</div>
) : (
<div className="budget-placeholder">
<div className="placeholder-icon">🗳</div>
<p>No votes cast yet. Vote in the Hotel, Golf, Nightlife, and Excursion tabs and the budget will update automatically using real scraped prices.</p>
</div>
)}
</div>
)
}