[ice] budget tab: dynamic live prices from details, hide options without scraped data
This commit is contained in:
@@ -70,6 +70,16 @@
|
|||||||
margin-bottom: 0.25rem;
|
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 {
|
.leader-votes {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
@@ -136,12 +146,21 @@
|
|||||||
|
|
||||||
.breakdown-row {
|
.breakdown-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr repeat(3, 80px);
|
grid-template-columns: 1fr 1.4fr repeat(3, 70px);
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
border-bottom: 1px solid var(--border, #2a2a3a);
|
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 {
|
.breakdown-row:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +1,137 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import './BudgetTab.css'
|
import './BudgetTab.css'
|
||||||
|
|
||||||
// Map options to cost tiers based on seed data pricing signals
|
// ─── Price parsers ────────────────────────────────────────────────────────────
|
||||||
const TIER_BY_OPTION = {
|
|
||||||
// Hotels
|
function extractPrices(details) {
|
||||||
'hotel-corazon': 'budget',
|
if (!details || !details.length) return {}
|
||||||
'hotel-grand-fiesta': 'balanced',
|
const text = details.join(' ')
|
||||||
'hotel-pacifica': 'splurge',
|
const prices = {}
|
||||||
'hotel-breathless': 'splurge',
|
|
||||||
'hotel-secrets': 'splurge',
|
// Per-person package: "Apple exact-date quote: $2,016 pp" / "Costco package: $1,678.99 pp"
|
||||||
// Golf
|
const ppMatch = text.match(/\$([\d,]+(?:\.\d{2})?)\s*(?:pp|per person|per-person)/i)
|
||||||
'golf-palmilla': 'budget',
|
if (ppMatch) prices.perPerson = parseFloat(ppMatch[1].replace(/,/g, ''))
|
||||||
'golf-cabo-del-sol': 'balanced',
|
|
||||||
'golf-quivira': 'splurge',
|
// Per-night: "KAYAK from $212/night" / "KAYAK exact-date room rate: $335/night"
|
||||||
'golf-puerto-los-cabos': 'balanced',
|
const pnMatch = text.match(/\$([\d,]+(?:\.\d{2})?)\s*(?:\/night|per night|per-night)/i)
|
||||||
// Nightlife
|
if (pnMatch) prices.perNight = parseFloat(pnMatch[1].replace(/,/g, ''))
|
||||||
'nightlife-mango-deck': 'budget',
|
|
||||||
'nightlife-cabo-bash': 'balanced',
|
// Group-total with size: "Pool island for 4: $884" / "Gold package for 16 guests: $1,700"
|
||||||
'nightlife-cabo-agency': 'balanced',
|
// Group-total with size: "Pool island for 4: $884" / "Gold package for 16 guests: $1,700"
|
||||||
'nightlife-taboo': 'splurge',
|
const groupMatch = text.match(/(?:for|at)\s+(\d+)[^\d$]*\$([\d,]+(?:\.\d{2})?)/i)
|
||||||
// Excursions
|
|| text.match(/\$([\d,]+(?:\.\d{2})?)\s+(?:total|for\s+\d+)/i)
|
||||||
'excursion-whale-public': 'budget',
|
if (groupMatch) {
|
||||||
'excursion-atv': 'balanced',
|
prices.groupTotal = parseFloat((groupMatch[2] || groupMatch[1]).replace(/,/g, ''))
|
||||||
'excursion-sail': 'balanced',
|
const sizeMatch = text.match(/for\s+(\d+)\s*[^\d$]*/i)
|
||||||
'excursion-whale-private': 'splurge',
|
if (sizeMatch) prices.groupSize = parseInt(sizeMatch[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cost per person by category and tier (from seed data planning numbers)
|
// Simple "From $1,504 total"
|
||||||
const COST_PER_PERSON = {
|
if (!prices.groupTotal) {
|
||||||
hotel: { budget: 450, balanced: 850, splurge: 1250 },
|
const totalMatch = text.match(/(?:from\s+)?\$([\d,]+(?:\.\d{2})?)\s+total/i)
|
||||||
golf: { budget: 130, balanced: 180, splurge: 250 },
|
if (totalMatch) prices.groupTotal = parseFloat(totalMatch[1].replace(/,/g, ''))
|
||||||
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]
|
// 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])
|
||||||
|
}
|
||||||
|
|
||||||
const CATEGORY_LABELS = {
|
// 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',
|
hotel: '🏨 Hotel',
|
||||||
golf: '⛳ Golf',
|
golf: '⛳ Golf',
|
||||||
nightlife: '🎧 Nightlife',
|
nightlife: '🎧 Nightlife',
|
||||||
excursion: '🚤 Excursion',
|
excursion: '🚤 Excursion',
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOptionTier(optionId) {
|
const GROUP_SIZES = [8, 10, 12]
|
||||||
return TIER_BY_OPTION[optionId] || 'balanced'
|
|
||||||
}
|
|
||||||
|
|
||||||
function calcTierTotal(tier, groupSize) {
|
// ─── Main component ─────────────────────────────────────────────────────────
|
||||||
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) {
|
function getWinningOption(options, categoryId) {
|
||||||
const catOpts = options.filter(o => o.categoryId === categoryId && o.approved)
|
const catOpts = options.filter(o => o.categoryId === categoryId && o.approved)
|
||||||
@@ -73,21 +141,48 @@ function getWinningOption(options, categoryId) {
|
|||||||
, catOpts[0])
|
, catOpts[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTierFromCategoryVotes(options, categoryId) {
|
|
||||||
const winning = getWinningOption(options, categoryId)
|
|
||||||
if (!winning) return 'balanced'
|
|
||||||
return getOptionTier(winning.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
function dominantTier(tiers) {
|
function dominantTier(tiers) {
|
||||||
const counts = { budget: 0, balanced: 0, splurge: 0 }
|
const counts = { budget: 0, balanced: 0, splurge: 0 }
|
||||||
Object.values(tiers).forEach(t => { if (t) counts[t] = (counts[t] || 0) + 1 })
|
Object.values(tiers).forEach(t => { if (t) counts[t] = (counts[t] || 0) + 1 })
|
||||||
if (counts.splurge >= 2) return 'splurge'
|
if (counts.splurge >= 2) return 'splurge'
|
||||||
if (counts.budget >= 2) return 'budget'
|
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'
|
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 }) {
|
export default function BudgetTab({ options, categories }) {
|
||||||
const votingCategories = ['hotel', 'golf', 'nightlife', 'excursion']
|
const votingCategories = ['hotel', 'golf', 'nightlife', 'excursion']
|
||||||
|
|
||||||
@@ -99,67 +194,106 @@ export default function BudgetTab({ options, categories }) {
|
|||||||
return result
|
return result
|
||||||
}, [options])
|
}, [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 tiers = useMemo(() => {
|
||||||
const t = {}
|
const t = {}
|
||||||
votingCategories.forEach(cat => {
|
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
|
return t
|
||||||
}, [options])
|
}, [pricedLeaders])
|
||||||
|
|
||||||
const overallTier = useMemo(() => dominantTier(tiers), [tiers])
|
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(() => {
|
const totals = useMemo(() => {
|
||||||
return GROUP_SIZES.map(size => {
|
return GROUP_SIZES.map(size => {
|
||||||
const tier = overallTier
|
const sum = Object.values(costRows).reduce((acc, row) => acc + (row.pp || 0), 0)
|
||||||
const { perPerson, total } = calcTierTotal(tier, size)
|
return { size, perPerson: Math.round(sum), total: Math.round(sum * size) }
|
||||||
return { size, tier, perPerson, total }
|
|
||||||
})
|
})
|
||||||
}, [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 tierColor = { budget: '#22c55e', balanced: '#3b82f6', splurge: '#f97316' }
|
||||||
|
const tierLabel = { budget: '💰 Budget', balanced: '⚖️ Balanced', splurge: '💎 Splurge' }
|
||||||
const hasAnyVotes = Object.values(leaders).some(o => o && o.votes?.length > 0)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="budget-tab">
|
<div className="budget-tab">
|
||||||
<div className="budget-header">
|
<div className="budget-header">
|
||||||
<h2>Trip Budget Calculator</h2>
|
<h2>Trip Budget Calculator</h2>
|
||||||
<p>Costs update live based on leading votes in each category</p>
|
<p>Prices sourced from live travel data — updates as votes come in</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category leaders */}
|
{/* Category leaders with real prices */}
|
||||||
<div className="budget-leaders">
|
<div className="budget-leaders">
|
||||||
<h3>Current Leaders by Category</h3>
|
<h3>Current Leaders — Live Prices</h3>
|
||||||
|
{Object.keys(pricedLeaders).length > 0 ? (
|
||||||
<div className="leaders-grid">
|
<div className="leaders-grid">
|
||||||
{votingCategories.map(cat => {
|
{votingCategories.map(cat => {
|
||||||
const leader = leaders[cat]
|
const leader = pricedLeaders[cat]
|
||||||
|
if (!leader) return null
|
||||||
const catTier = tiers[cat]
|
const catTier = tiers[cat]
|
||||||
|
const priceLabel = getPriceLabel(leader)
|
||||||
return (
|
return (
|
||||||
<div key={cat} className="leader-card" style={{ '--cat-color': CATEGORY_LABELS[cat].split(' ')[0] }}>
|
<div key={cat} className="leader-card" style={{ '--cat-color': tierColor[catTier] }}>
|
||||||
<div className="leader-cat">{CATEGORY_LABELS[cat]}</div>
|
<div className="leader-cat">{CAT_LABELS[cat]}</div>
|
||||||
{leader && leader.votes?.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<div className="leader-name">{leader.name}</div>
|
<div className="leader-name">{leader.name}</div>
|
||||||
|
{priceLabel && <div className="leader-price">{priceLabel}</div>}
|
||||||
<div className="leader-tier" style={{ color: tierColor[catTier] }}>
|
<div className="leader-tier" style={{ color: tierColor[catTier] }}>
|
||||||
{tierLabel[catTier]}
|
{tierLabel[catTier]}
|
||||||
</div>
|
</div>
|
||||||
<div className="leader-votes">{leader.votes.length} vote{leader.votes.length !== 1 ? 's' : ''}</div>
|
<div className="leader-votes">
|
||||||
</>
|
{leader.votes?.length > 0
|
||||||
) : (
|
? `${leader.votes.length} vote${leader.votes.length !== 1 ? 's' : ''}`
|
||||||
<>
|
: 'no votes yet'}
|
||||||
<div className="leader-name no-votes">No votes yet</div>
|
</div>
|
||||||
<div className="leader-tier" style={{ color: '#888' }}>—</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Tier selector — highlight dominant */}
|
{/* Tier summary pills */}
|
||||||
<div className="budget-tiers">
|
<div className="budget-tiers">
|
||||||
{['budget', 'balanced', 'splurge'].map(tier => (
|
{['budget', 'balanced', 'splurge'].map(tier => (
|
||||||
<div
|
<div
|
||||||
@@ -173,69 +307,63 @@ export default function BudgetTab({ options, categories }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cost breakdown */}
|
{/* Dynamic cost breakdown */}
|
||||||
{hasAnyVotes ? (
|
{hasPricedLeaders ? (
|
||||||
<div className="budget-breakdown">
|
<div className="budget-breakdown">
|
||||||
<h3>Estimated Cost — {tierLabel[overallTier]} Tier</h3>
|
<h3>Estimated Cost — {tierLabel[overallTier]} Tier</h3>
|
||||||
<div className="breakdown-table">
|
<div className="breakdown-table">
|
||||||
<div className="breakdown-row header-row">
|
<div className="breakdown-row header-row">
|
||||||
<span>Line Item</span>
|
<span>Line Item</span>
|
||||||
|
<span>Source</span>
|
||||||
{GROUP_SIZES.map(s => <span key={s}>{s} guys</span>)}
|
{GROUP_SIZES.map(s => <span key={s}>{s} guys</span>)}
|
||||||
</div>
|
</div>
|
||||||
<div className="breakdown-row">
|
|
||||||
<span>✈️ Flight estimate</span>
|
{[
|
||||||
{GROUP_SIZES.map(s => <span key={s}>${(COST_PER_PERSON.flight).toLocaleString()}</span>)}
|
costRows.flight,
|
||||||
</div>
|
costRows.hotel,
|
||||||
<div className="breakdown-row">
|
costRows.golf,
|
||||||
<span>🏨 Hotel ({COST_PER_PERSON.hotel[overallTier]}/pp)</span>
|
costRows.nightlife,
|
||||||
{GROUP_SIZES.map(s => <span key={s}>${(COST_PER_PERSON.hotel[overallTier]).toLocaleString()}</span>)}
|
costRows.excursion,
|
||||||
</div>
|
costRows.transfer,
|
||||||
<div className="breakdown-row">
|
costRows.food,
|
||||||
<span>⛳ Golf ({COST_PER_PERSON.golf[overallTier]}/pp)</span>
|
].map(row => row && (
|
||||||
{GROUP_SIZES.map(s => <span key={s}>${(COST_PER_PERSON.golf[overallTier]).toLocaleString()}</span>)}
|
<div className="breakdown-row" key={row.label}>
|
||||||
</div>
|
<span>{row.label}</span>
|
||||||
<div className="breakdown-row">
|
<span className="source-cell">{row.source || '—'}</span>
|
||||||
<span>🎧 Nightlife ({COST_PER_PERSON.nightlife[overallTier]}/pp)</span>
|
{GROUP_SIZES.map(s => (
|
||||||
{GROUP_SIZES.map(s => <span key={s}>${(COST_PER_PERSON.nightlife[overallTier]).toLocaleString()}</span>)}
|
<span key={s}>{formatPrice(row.pp)}</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>
|
||||||
|
))}
|
||||||
|
|
||||||
<div className="breakdown-row total-row">
|
<div className="breakdown-row total-row">
|
||||||
<span>Est. Total / Person</span>
|
<span>Est. Per Person</span>
|
||||||
|
<span className="source-cell">sum</span>
|
||||||
{totals.map(t => (
|
{totals.map(t => (
|
||||||
<span key={t.size} style={{ color: tierColor[overallTier], fontWeight: 700 }}>
|
<span key={t.size} style={{ color: tierColor[overallTier], fontWeight: 700 }}>
|
||||||
${t.perPerson.toLocaleString()}
|
{formatPrice(t.perPerson)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="breakdown-row group-row">
|
<div className="breakdown-row group-row">
|
||||||
<span>Est. Group Total</span>
|
<span>Est. Group Total</span>
|
||||||
|
<span className="source-cell"></span>
|
||||||
{totals.map(t => (
|
{totals.map(t => (
|
||||||
<span key={t.size} style={{ color: tierColor[overallTier], fontWeight: 700 }}>
|
<span key={t.size} style={{ color: tierColor[overallTier], fontWeight: 700 }}>
|
||||||
${t.total.toLocaleString()}
|
{formatPrice(t.total)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="budget-note">
|
<p className="budget-note">
|
||||||
* Estimates based on current leading votes. Prices may vary. Add extra for bar tabs and tips.
|
* Prices from live scraped data. Hotel rates are package or nightly × 3 nights.
|
||||||
Price data from {options.find(o => o.categoryId === 'hotel')?.details?.[0]?.match(/\d{4}-\d{2}-\d{2}/)?.[0] || 'April 2026'}.
|
Per-person costs shown are the same for all group sizes. Add bar tabs and tips separately.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="budget-placeholder">
|
<div className="budget-placeholder">
|
||||||
<div className="placeholder-icon">🗳️</div>
|
<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>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user