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 (
Prices sourced from live travel data — updates as votes come in
Waiting for pricing data. Options without live prices from travel sites are hidden until the scraper updates them.
* 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.