diff --git a/client/src/components/BudgetTab.css b/client/src/components/BudgetTab.css index f2ed28f..70aed02 100644 --- a/client/src/components/BudgetTab.css +++ b/client/src/components/BudgetTab.css @@ -70,6 +70,16 @@ margin-bottom: 0.25rem; } +.leader-price { + font-size: 0.72rem; + color: #00d4ff; + font-weight: 600; + margin-bottom: 0.25rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .leader-votes { font-size: 0.7rem; opacity: 0.5; @@ -136,12 +146,21 @@ .breakdown-row { display: grid; - grid-template-columns: 1fr repeat(3, 80px); + grid-template-columns: 1fr 1.4fr repeat(3, 70px); padding: 0.5rem 0.75rem; font-size: 0.8rem; border-bottom: 1px solid var(--border, #2a2a3a); } +.source-cell { + font-size: 0.65rem; + opacity: 0.6; + text-align: left !important; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .breakdown-row:last-child { border-bottom: none; } diff --git a/client/src/components/BudgetTab.jsx b/client/src/components/BudgetTab.jsx index 9a3d887..4fc8997 100644 --- a/client/src/components/BudgetTab.jsx +++ b/client/src/components/BudgetTab.jsx @@ -1,69 +1,137 @@ 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', +// ─── 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 } -// 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, +// ─── 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) } -const GROUP_SIZES = [8, 10, 12] +// ─── Resolve per-person cost for an option ─────────────────────────────────── -const CATEGORY_LABELS = { +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', } -function getOptionTier(optionId) { - return TIER_BY_OPTION[optionId] || 'balanced' -} +const GROUP_SIZES = [8, 10, 12] -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 } -} +// ─── Main component ───────────────────────────────────────────────────────── function getWinningOption(options, categoryId) { const catOpts = options.filter(o => o.categoryId === categoryId && o.approved) @@ -73,21 +141,48 @@ function getWinningOption(options, categoryId) { , 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' } +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'] @@ -99,67 +194,106 @@ export default function BudgetTab({ options, categories }) { 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 => { - t[cat] = getTierFromCategoryVotes(options, cat) + const leader = pricedLeaders[cat] + if (!leader) { t[cat] = 'balanced'; return } + const pp = resolvePerPerson(leader, null) + t[cat] = classifyTier(leader.id, pp) }) return t - }, [options]) + }, [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 tier = overallTier - const { perPerson, total } = calcTierTotal(tier, size) - return { size, tier, perPerson, total } + const sum = Object.values(costRows).reduce((acc, row) => acc + (row.pp || 0), 0) + return { size, perPerson: Math.round(sum), total: Math.round(sum * size) } }) - }, [overallTier]) + }, [costRows]) + + const hasPricedLeaders = Object.keys(pricedLeaders).length > 0 - 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) + const tierLabel = { budget: '💰 Budget', balanced: '⚖️ Balanced', splurge: '💎 Splurge' } return (
Costs update live based on leading votes in each category
+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.
+- * 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'}. + * 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.
+No votes cast yet. Vote in the Hotel, Golf, Nightlife, and Excursion tabs and the budget will update automatically using real scraped prices.