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 (

Trip Budget Calculator

Prices sourced from live travel data — updates as votes come in

{/* Category leaders with real prices */}

Current Leaders — Live Prices

{Object.keys(pricedLeaders).length > 0 ? (
{votingCategories.map(cat => { const leader = pricedLeaders[cat] if (!leader) return null const catTier = tiers[cat] const priceLabel = getPriceLabel(leader) return (
{CAT_LABELS[cat]}
{leader.name}
{priceLabel &&
{priceLabel}
}
{tierLabel[catTier]}
{leader.votes?.length > 0 ? `${leader.votes.length} vote${leader.votes.length !== 1 ? 's' : ''}` : 'no votes yet'}
) })}
) : (

Waiting for pricing data. Options without live prices from travel sites are hidden until the scraper updates them.

)}
{/* Tier summary pills */}
{['budget', 'balanced', 'splurge'].map(tier => (
{tierLabel[tier]}
{overallTier === tier &&
Current
}
))}
{/* Dynamic cost breakdown */} {hasPricedLeaders ? (

Estimated Cost — {tierLabel[overallTier]} Tier

Line Item Source {GROUP_SIZES.map(s => {s} guys)}
{[ costRows.flight, costRows.hotel, costRows.golf, costRows.nightlife, costRows.excursion, costRows.transfer, costRows.food, ].map(row => row && (
{row.label} {row.source || '—'} {GROUP_SIZES.map(s => ( {formatPrice(row.pp)} ))}
))}
Est. Per Person sum {totals.map(t => ( {formatPrice(t.perPerson)} ))}
Est. Group Total {totals.map(t => ( {formatPrice(t.total)} ))}

* 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.

) : (
🗳️

No votes cast yet. Vote in the Hotel, Golf, Nightlife, and Excursion tabs and the budget will update automatically using real scraped prices.

)}
) }