const { useState, useEffect } = React;
const DEFAULTS = window.CITY_DEFAULTS;
const LABELS = {
en: {
planTrip: "Plan Your Trip",
destinations: "Destinations",
addCity: "Add another city",
remove: "Remove",
requestProposal: "Request Proposal",
reviewProposal: "Review & Submit Proposal",
summaryPanel: "Budget Summary",
budgetBox: "Total Trip Budget",
perDay: "Per Day",
breakdown: "Breakdown by Category",
accommodation: "Accommodation",
transportation: "Transportation",
food: "Food & Dining",
extras: "Gifts & Extras",
city: "City",
days: "Days",
dailyCost: "Daily Cost",
cityTotal: "City Total",
detailsPerCity: "Details per City",
totalBudget: "Total Budget",
perDayAverage: "Per Day Average",
tripOverview: "Trip Overview",
tripDuration: "Trip Duration",
citiesVisited: "Cities Visited",
downloadHTML: "Download HTML Report",
downloadPDF: "Download PDF Report",
shareEmail: "Share via Email",
shareWhatsApp: "Share on WhatsApp",
shareTwitter: "Share on Twitter",
shareFacebook: "Share on Facebook",
confirmSend: "Confirm & Send Email",
cancel: "Cancel",
email: "Email Address",
phone: "Phone Number",
fullName: "Full Name",
birthDate: "Birth Date",
passportNumber: "Passport Number",
wiseaccount: "WISE account IBAN EUR",
personalID: "Personal ID / National ID",
issueDate: "Issue Date",
expiryDate: "Expiry Date",
issuingAuthority: "Issuing Authority",
mealsOptions: "My meal options for this period are:",
accommodationType: "Accommodation Type",
stars: "Stars",
spendAmount: "Amount of Money",
attractions: "Paid Attractions",
safetyBuffer: "Consider adding",
safetyBufferRest: "as a safety buffer for unexpected expenses.",
withBuffer: "Total with Buffer"
},
pt: {
planTrip: "Planeje Sua Viagem",
destinations: "Destinos",
addCity: "Adicionar outra cidade",
remove: "Remover",
requestProposal: "Solicitar Proposta",
reviewProposal: "Revisar e Enviar Proposta",
summaryPanel: "Resumo do Orçamento",
budgetBox: "Orçamento Total da Viagem",
perDay: "Por Dia",
breakdown: "Distribuição por Categoria",
accommodation: "Acomodação",
transportation: "Transporte",
food: "Alimentação",
extras: "Presentes & Extras",
city: "Cidade",
days: "Dias",
dailyCost: "Custo Diário",
cityTotal: "Total da Cidade",
detailsPerCity: "Detalhes por Cidade",
totalBudget: "Orçamento Total",
perDayAverage: "Média por Dia",
tripOverview: "Visão Geral da Viagem",
tripDuration: "Duração da Viagem",
citiesVisited: "Cidades Visitadas",
downloadHTML: "Baixar Relatório HTML",
downloadPDF: "Baixar Relatório PDF",
shareEmail: "Compartilhar por Email",
shareWhatsApp: "Compartilhar no WhatsApp",
shareTwitter: "Compartilhar no Twitter",
shareFacebook: "Compartilhar no Facebook",
confirmSend: "Confirmar e Enviar E-mail",
cancel: "Cancelar",
email: "E-mail",
phone: "Telefone",
fullName: "Nome Completo",
birthDate: "Data de Nascimento",
passportNumber: "Número do Passaporte",
wiseaccount: "IBAN da conta WISE em EURO",
personalID: "Identidade / CPF",
issueDate: "Data de Emissão",
expiryDate: "Data de Validade",
issuingAuthority: "Órgão Emissor",
mealsOptions: "Minhas opções de refeição para esse período:",
accommodationType: "Tipo de Acomodação",
stars: "Estrelas",
spendAmount: "Valor de Gastos",
attractions: "Atrações Pagas",
safetyBuffer: "Considere adicionar",
safetyBufferRest: "como reserva para imprevistos.",
withBuffer: "Total com Reserva"
}
};
const transportOptionsByLang = {
en: [
{ label: "Flight ✈️", value: "Flight", icon: "✈️" },
{ label: "Train 🚆", value: "Train", icon: "🚆" },
{ label: "Bus 🚌", value: "Bus", icon: "🚌" },
{ label: "Car 🚗", value: "Car", icon: "🚗" },
{ label: "Boat ⛴️", value: "Boat", icon: "⛴️" }
],
pt: [
{ label: "Voo ✈️", value: "Flight", icon: "✈️" },
{ label: "Trem 🚆", value: "Train", icon: "🚆" },
{ label: "Ônibus 🚌", value: "Bus", icon: "🚌" },
{ label: "Carro 🚗", value: "Car", icon: "🚗" },
{ label: "Barco ⛴️", value: "Boat", icon: "⛴️" }
]
};
const transportPriceOptionsByLang = {
en: [
{ label: "very cheap", value: "very cheap" },
{ label: "cheap", value: "cheap" },
{ label: "average", value: "average" },
{ label: "expensive", value: "expensive" },
{ label: "very expensive", value: "very expensive" }
],
pt: [
{ label: "muito barato", value: "very cheap" },
{ label: "barato", value: "cheap" },
{ label: "médio", value: "average" },
{ label: "caro", value: "expensive" },
{ label: "muito caro", value: "very expensive" }
]
};
const foodOptionsByLang = {
en: ["Breakfast", "Snack1", "Lunch", "Snack2", "Dinner", "Snack3"],
pt: ["Café da manhã", "Lanche1", "Almoço", "Lanche2", "Jantar", "Lanche3"]
};
const starsOptionsByLang = {
en: [
{ label: "1 star", value: "1" },
{ label: "2 stars", value: "2" },
{ label: "3 stars", value: "3" },
{ label: "4 stars", value: "4" },
{ label: "5 stars", value: "5" }
],
pt: [
{ label: "1 estrela", value: "1" },
{ label: "2 estrelas", value: "2" },
{ label: "3 estrelas", value: "3" },
{ label: "4 estrelas", value: "4" },
{ label: "5 estrelas", value: "5" }
]
};
const accomOptionsByLang = {
en: ["Hotel", "Hostel", "Airbnb", "Own lodge", "Others"],
pt: ["Hotel", "Hostel", "Airbnb", "Próprio", "Outros"]
};
const spendOptionsByLang = {
en: [
{ label: "No spend", value: "No spend" },
{ label: "very small", value: "Very Small" },
{ label: "small", value: "Small" },
{ label: "average", value: "Average" },
{ label: "high", value: "High" },
{ label: "very high", value: "Very High" }
],
pt: [
{ label: "Nada", value: "No spend" },
{ label: "muito pouco", value: "Very Small" },
{ label: "pouco", value: "Small" },
{ label: "mais ou menos", value: "Average" },
{ label: "muito", value: "High" },
{ label: "muitíssimo", value: "Very High" }
]
};
const attractionsOptionsByLang = {
en: [
{ label: "0", value: "0" }, { label: "1", value: "1" }, { label: "2", value: "2" }, { label: "3", value: "3" },
{ label: "4", value: "4" }, { label: "5", value: "5" }, { label: "6", value: "6" }, { label: "7", value: "7" },
{ label: "8", value: "8" }, { label: "9", value: "9" }, { label: "10", value: "10" }, { label: ">10", value: ">10" }
],
pt: [
{ label: "0", value: "0" }, { label: "1", value: "1" }, { label: "2", value: "2" }, { label: "3", value: "3" },
{ label: "4", value: "4" }, { label: "5", value: "5" }, { label: "6", value: "6" }, { label: "7", value: "7" },
{ label: "8", value: "8" }, { label: "9", value: "9" }, { label: "10", value: "10" }, { label: ">10", value: ">10" }
]
};
function lookupMetric(cityRaw, metric) {
if (!cityRaw) return DEFAULTS && DEFAULTS[metric] ? DEFAULTS[metric] : {};
const canon = v => v ? v.normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim().toLowerCase() : "";
const inputCanon = canon(cityRaw);
// First, try to find it as a city
const cityKey = Object.keys(window.CITY_MAPPING || {}).find(c => canon(c) === inputCanon);
let country, level;
if (!cityKey) {
// If not found as a city, check if it's a country name
const countryKey = Object.keys(window.COUNTRY_TIERS || {}).find(c => canon(c) === inputCanon);
if (countryKey) {
// User entered a country name
country = countryKey;
level = "medium"; // Default level for countries
} else {
// Not found as city or country, use defaults
return DEFAULTS && DEFAULTS[metric] ? DEFAULTS[metric] : {};
}
} else {
// Found as a city
const cityData = window.CITY_MAPPING[cityKey];
country = cityData.country;
level = cityData.level;
}
const tier = window.COUNTRY_TIERS[country];
if (!tier || !window.BUDGET_TIERS[tier]) {
return DEFAULTS && DEFAULTS[metric] ? DEFAULTS[metric] : {};
}
const basePrices = window.BUDGET_TIERS[tier][metric];
if (!basePrices) {
return DEFAULTS && DEFAULTS[metric] ? DEFAULTS[metric] : {};
}
const multiplier = window.COST_MULTIPLIERS[level] || 1.0;
const adjustedPrices = {};
if (metric === "MEAL_PRICES" || metric === "SPEND_LEVELS" || metric === "ATTRACTION_PRICES") {
Object.keys(basePrices).forEach(key => {
adjustedPrices[key] = Math.round(basePrices[key] * multiplier);
});
} else if (metric === "HOTEL_PRICES") {
Object.keys(basePrices).forEach(accomType => {
adjustedPrices[accomType] = {};
Object.keys(basePrices[accomType]).forEach(stars => {
adjustedPrices[accomType][stars] = Math.round(basePrices[accomType][stars] * multiplier);
});
});
}
return adjustedPrices;
}
function diffDays(start, end) {
if (!start || !end) return 1;
const a = new Date(start), b = new Date(end);
const ms = (b - a);
return Math.max(1, Math.round(ms/(1000*60*60*24)));
}
async function calculateCityTotalsByCategory(city, idx, cities) {
const meals = lookupMetric(city.city, "MEAL_PRICES");
const accoms = lookupMetric(city.city, "HOTEL_PRICES");
const spends = lookupMetric(city.city, "SPEND_LEVELS");
const atts = lookupMetric(city.city, "ATTRACTION_PRICES");
const accom = city.accomType && city.stars && accoms[city.accomType]
? (accoms[city.accomType][city.stars] || 0)
: 0;
let transport = 0;
if (idx > 0 && city.transport && cities[idx-1].city && city.city) {
const prevCityName = cities[idx-1].city;
const currentCityName = city.city;
let prevCoords = window.GEOCODE_CACHE[prevCityName];
let currentCoords = window.GEOCODE_CACHE[currentCityName];
if (!prevCoords && window.geocodeCity) {
prevCoords = await window.geocodeCity(prevCityName);
if (prevCoords) window.GEOCODE_CACHE[prevCityName] = prevCoords;
}
if (!currentCoords && window.geocodeCity) {
currentCoords = await window.geocodeCity(currentCityName);
if (currentCoords) window.GEOCODE_CACHE[currentCityName] = currentCoords;
}
if (prevCoords && currentCoords) {
const distance = window.calculateDistance(
prevCoords.lat, prevCoords.lon,
currentCoords.lat, currentCoords.lon
);
const cityData = window.CITY_MAPPING[city.city];
const tier = cityData && cityData.country ?
(window.COUNTRY_TIERS[cityData.country] || "Medium") : "Medium";
const rates = window.TRANSPORT_RATES[tier] || window.TRANSPORT_RATES["Medium"];
const rate = rates ? rates[city.transport] : undefined;
if (rate) {
transport = rate.base + (distance * rate.perKm);
if (city.transportPrice && window.TRANSPORT_COST_MULTIPLIERS[city.transportPrice]) {
transport *= window.TRANSPORT_COST_MULTIPLIERS[city.transportPrice];
}
}
}
}
let food = 0;
Object.keys(meals).forEach(meal => {
if (city.foodChecks && city.foodChecks[meal]) food += meals[meal] || 0;
});
const extras = (city.spend ? (spends[city.spend] || 0) : 0) + (city.attractions ? (atts[city.attractions] || 0) : 0);
return { accommodation: accom, transport, food, extras };
}
async function sumTotalsByCategory(cities) {
const results = await Promise.all(
cities.map((city, idx) => calculateCityTotalsByCategory(city, idx, cities))
);
return results.reduce((agg, curr) => ({
accommodation: agg.accommodation + curr.accommodation,
transport: agg.transport + curr.transport,
food: agg.food + curr.food,
extras: agg.extras + curr.extras
}), {accommodation:0, transport:0, food:0, extras:0});
}
async function calculateCityTotal(city, idx, cities) {
const cat = await calculateCityTotalsByCategory(city, idx, cities);
return cat.accommodation + cat.transport + cat.food + cat.extras;
}
function generateReport(cities, cityTotals, tripTotal, tripDays, startDate, endDate, totalsCat) {
const reportHTML = `
Trip Overview
${tripTotal.toFixed(2)}€
Total Budget
${(tripTotal/tripDays).toFixed(2)}€/day
Per Day Average
${tripDays} days
Trip Duration
${cities.length}
Cities Visited
Budget Breakdown by Category
🏨 Accommodation
${totalsCat.accommodation.toFixed(2)}€
💰 transportation
${totalsCat.transport.toFixed(2)}€
🍽️ Food & Dining
${totalsCat.food.toFixed(2)}€
🎁 Gifts & Extras
${totalsCat.extras.toFixed(2)}€
Cities & Daily Breakdown
| City |
Days |
Daily Cost |
City Total |
${cities.map((city, idx) => {
const days = city.startDate && city.endDate ? Math.max(1, Math.round((new Date(city.endDate) - new Date(city.startDate))/(1000*60*60*24))) : 1;
const total = cityTotals[idx] || 0;
const daily = days ? (total/days).toFixed(2) : total.toFixed(2);
return `
| ${city.city} |
${days} |
${daily}€ |
${total.toFixed(2)}€ |
`;
}).join('')}
Details per City
${cities.map((city, idx) => `
${city.city}
| Dates: ${city.startDate} to ${city.endDate} |
Accommodation: ${city.stars}★ ${city.accomType} |
| Transport: ${city.transport} (${city.transportPrice}) |
Spending: ${city.spend} |
`).join('')}
Total Trip Budget: €${tripTotal.toFixed(2)}
`;
return reportHTML;
}
function transportIcon(transportType) {
const icons = {
"Flight": "✈️",
"Train": "🚆",
"Bus": "🚌",
"Car": "🚗",
"Boat": "⛴️"
};
return icons[transportType] || "➡️";
}
function TripPlannerApp() {
const [cities, setCities] = useState([
{
city: "", startDate: "", endDate: "", transport: "", transportPrice: "",
foodChecks: {}, stars: "", accomType: "", spend: "", attractions: ""
}
]);
const [language, setLanguage] = useState('en');
const transportOptions = transportOptionsByLang[language];
const transportPriceOptions = transportPriceOptionsByLang[language];
const foodOptions = foodOptionsByLang[language];
const starsOptions = starsOptionsByLang[language];
const accomOptions = accomOptionsByLang[language];
const spendOptions = spendOptionsByLang[language];
const attractionsOptions = attractionsOptionsByLang[language];
const [submitted, setSubmitted] = useState(false);
const [filteredCities, setFilteredCities] = useState([]);
const [cityTotals, setCityTotals] = useState([]);
const [totalsCat, setTotalsCat] = useState({accommodation:0, transport:0, food:0, extras:0});
const [showProposalForm, setShowProposalForm] = useState(false);
const [proposalData, setProposalData] = useState({
email: '',
phone: '',
fullName: '',
passportNumber: '',
issueDate: '',
expiryDate: '',
issuingAuthority: '',
personalID: '',
birthDate: '',
wiseaccount: '',
});
const [showReviewModal, setShowReviewModal] = useState(false);
const [reportHTML, setReportHTML] = useState('');
const [currency, setCurrency] = useState('EUR'); // Add this line
// Add these new state variables in TripPlannerApp component, after existing useState declarations:
const [showBudgetBubble, setShowBudgetBubble] = useState(false);
const [bubblePosition, setBubblePosition] = useState({ x: 50, y: 100 });
// Add this useEffect to detect when total budget card is visible (runs after calculations)
useEffect(() => {
const checkBudgetCard = () => {
if (tripTotal > 0) {
const budgetBox = document.querySelector('.budget-box');
if (budgetBox) {
const rect = budgetBox.getBoundingClientRect();
const isVisible = rect.top < window.innerHeight && rect.bottom > 0;
setShowBudgetBubble(!isVisible);
} else {
setShowBudgetBubble(true);
}
}
};
checkBudgetCard();
window.addEventListener('scroll', checkBudgetCard);
window.addEventListener('resize', checkBudgetCard);
return () => {
window.removeEventListener('scroll', checkBudgetCard);
window.removeEventListener('resize', checkBudgetCard);
};
}, [tripTotal, cities]);
const allCities = Object.keys(window.CITY_MAPPING || {}).sort();
useEffect(() => {
async function calc() {
const totals = await Promise.all(
cities.map((city, idx) => calculateCityTotal(city, idx, cities))
);
setCityTotals(totals);
const catTotals = await sumTotalsByCategory(cities);
setTotalsCat(catTotals);
}
calc();
}, [cities]);
const handleCityChange = (idx, field, value) => {
const updated = [...cities];
updated[idx][field] = value;
setCities(updated);
setSubmitted(false);
if (field === "city" && value && value.length >= 1) {
const filtered = allCities.filter(city =>
city.toLowerCase().startsWith(value.toLowerCase())
);
setFilteredCities(filtered);
} else {
setFilteredCities([]);
}
};
const handleFoodCheck = (idx, meal) => {
const updated = [...cities];
// Map PT display names to English storage keys
const englishKey = meal === "Café da manhã" ? "Breakfast" :
meal === "Lanche1" ? "Snack1" :
meal === "Almoço" ? "Lunch" :
meal === "Lanche2" ? "Snack2" :
meal === "Jantar" ? "Dinner" :
meal === "Lanche3" ? "Snack3" : meal;
updated[idx].foodChecks = {...(updated[idx].foodChecks||{}), [englishKey]: !updated[idx]?.foodChecks?.[englishKey]};
setCities(updated);
setSubmitted(false);
};
// Add this function near the top with other helper functions
function formatCurrency(amount, currency) {
const rate = currency === 'BRL' ? 6.2 : 1; // EUR to BRL ≈ 6.2 (update as needed)
const converted = amount * rate;
const symbol = currency === 'BRL' ? 'R$ ' : '€';
return symbol + converted.toLocaleString('pt-BR', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
}
const addCity = () => {
setCities([...cities, { city: "", startDate: "", endDate: "", transport: "", transportPrice: "", foodChecks: {}, stars: "", accomType: "", spend: "", attractions: "" }]);
setSubmitted(false);
};
const removeCity = (idx) => {
if (cities.length === 1) return;
setCities(cities.filter((_, i) => i !== idx));
setSubmitted(false);
};
const calculate = e => {
e.preventDefault();
setSubmitted(true);
};
const resetAll = () => {
setCities([{ city: "", startDate: "", endDate: "", transport: "", transportPrice: "", foodChecks: {}, stars: "", accomType: "", spend: "", attractions: "" }]);
setSubmitted(false);
setFilteredCities([]);
};
const getCityDays = (city) => city.startDate && city.endDate ? diffDays(city.startDate, city.endDate) : 1;
const startDate = cities[0]?.startDate || "";
const endDate = cities.length > 0 ? cities[cities.length-1].endDate : "";
const tripDays = (startDate && endDate) ? diffDays(startDate, endDate) : 1;
const cityDays = cities.map(getCityDays);
const cityPerDay = cityTotals.map((total, i) => cityDays[i] ? (total/cityDays[i]) : total);
const tripTotal = cityTotals.reduce((a,b) => a+b, 0);
const breakdown = [
{ key:"accommodation", label:"Accommodation", icon:"🏨", className:"fill-accomod", value: totalsCat.accommodation },
{ key:"transportation", label:"Transportation", icon:"💰", className:"fill-transport", value: totalsCat.transport },
{ key:"food", label:"Food & Dining", icon:"🍽️", className:"fill-food", value: totalsCat.food },
{ key:"extras", label:"Gifts & Extras", icon:"🎁", className:"fill-extras", value: totalsCat.extras }
];
const breakdownTotal = breakdown.reduce((a,b)=>a+b.value,0);
const buffer = tripTotal * 0.15;
const citiesToShow = filteredCities.length > 0 ? filteredCities : [];
const handleProposalSubmit = (e) => {
e.preventDefault();
// Generate the HTML report
const html = generateReport(cities, cityTotals, tripTotal, tripDays, startDate, endDate, totalsCat);
setReportHTML(html);
setShowReviewModal(true);
setShowProposalForm(false);
};
const confirmAndSendEmail = () => {
// Create hidden form for email submission
const form = document.createElement('form');
form.method = 'POST';
form.action = 'https://formsubmit.co/contact@tunvolo.com';
form.style.display = 'none';
// Create hidden input for redirect URL
const redirectInput = document.createElement('input');
redirectInput.type = 'hidden';
redirectInput.name = '_next'; // This special field tells formsubmit.co the redirect URL
// Replace with your desired URL - e.g., your own website's thank-you page
redirectInput.value = 'https://tunvolo.com/tool/';
// Append it to the form
form.appendChild(redirectInput);
// Add all proposal data
Object.keys(proposalData).forEach(key => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = key;
input.value = proposalData[key];
form.appendChild(input);
});
// Create detailed trip summary with all budget info
const tripSummary = `
=== TRIP BUDGET PROPOSAL ===
TRIP OVERVIEW:
- Dates: ${startDate ? new Date(startDate).toLocaleDateString() : '--'} to ${endDate ? new Date(endDate).toLocaleDateString() : '--'}
- Duration: ${tripDays} days
- Cities: ${cities.map(c => c.city).join(' → ')}
- Total Budget: €${tripTotal.toFixed(2)}
- Per Day Average: €${(tripTotal/tripDays).toFixed(2)}
BUDGET BREAKDOWN BY CATEGORY:
- 🏨 Accommodation: €${totalsCat.accommodation.toFixed(2)}
- 💰 Transportation: €${totalsCat.transport.toFixed(2)}
- 🍽️ Food & Dining: €${totalsCat.food.toFixed(2)}
- 🎁 Gifts & Extras: €${totalsCat.extras.toFixed(2)}
CITIES & DAILY BREAKDOWN:
${cities.map((city, idx) => {
const days = city.startDate && city.endDate ? Math.max(1, Math.round((new Date(city.endDate) - new Date(city.startDate))/(1000*60*60*24))) : 1;
const total = cityTotals[idx] || 0;
const daily = days ? (total/days).toFixed(2) : total.toFixed(2);
return `- ${city.city}: ${days} days | €${daily}/day | Total: €${total.toFixed(2)}`;
}).join('\n')}
DETAILS PER CITY:
${cities.map((city, idx) => `
${city.city}:
Dates: ${city.startDate} to ${city.endDate}
Accommodation: ${city.stars}★ ${city.accomType}
Transport: ${city.transport} (${city.transportPrice})
Meals: ${foodOptions.filter(f => city.foodChecks && city.foodChecks[f]).join(', ')}
Spending Level: ${city.spend}
Attractions: ${city.attractions}
`).join('\n')}
SAFETY BUFFER RECOMMENDATION:
- Suggested Buffer: €${buffer.toFixed(2)} (15%)
- Total with Buffer: €${(tripTotal + buffer).toFixed(2)}
`;
// Add trip summary
const tripInput = document.createElement('input');
tripInput.type = 'hidden';
tripInput.name = 'trip_summary';
tripInput.value = tripSummary;
form.appendChild(tripInput);
// Add subject
const subjectInput = document.createElement('input');
subjectInput.type = 'hidden';
subjectInput.name = '_subject';
subjectInput.value = `New Travel Proposal Request from ${proposalData.fullName}`;
form.appendChild(subjectInput);
// Add captcha disable
const captchaInput = document.createElement('input');
captchaInput.type = 'hidden';
captchaInput.name = '_captcha';
captchaInput.value = 'false';
form.appendChild(captchaInput);
document.body.appendChild(form);
form.submit();
setShowReviewModal(false);
alert('Thank you! Your proposal has been sent successfully! We will contact you back as soon as possible.');
};
// Add this new draggable bubble component right before the return statement:
const BudgetBubble = () => {
if (!showBudgetBubble || tripTotal === 0) return null;
const handleMouseDown = (e) => {
e.preventDefault();
e.stopPropagation();
const handleMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - startX;
const deltaY = moveEvent.clientY - startY;
setBubblePosition({
x: Math.max(10, Math.min(startBubbleX + deltaX, window.innerWidth - 120)),
y: Math.max(10, Math.min(startBubbleY + deltaY, window.innerHeight - 100))
});
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
const handleTouchStart = (e) => {
e.preventDefault();
e.stopPropagation();
const handleTouchMove = (moveEvent) => {
const touch = moveEvent.touches[0];
const deltaX = touch.clientX - startX;
const deltaY = touch.clientY - startY;
setBubblePosition({
x: Math.max(10, Math.min(startBubbleX + deltaX, window.innerWidth - 120)),
y: Math.max(10, Math.min(startBubbleY + deltaY, window.innerHeight - 100))
});
};
const handleTouchEnd = () => {
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
};
document.addEventListener('touchmove', handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd);
};
const currencySymbol = currency === 'BRL' ? 'R$' : '€';
return (
{formatCurrency(tripTotal, currency)}
{formatCurrency(tripTotal/tripDays, currency)}/day
);
};
return (
<>
{/* Language Selector */}
{/* Currency Selector */}
💲 {LABELS[language].planTrip}
{LABELS[language].destinations}
{LABELS[language].summaryPanel}
📅
{startDate ? new Date(startDate).toLocaleDateString() : "--"} - {endDate ? new Date(endDate).toLocaleDateString() : "--"}
{tripDays > 0 && (
{tripDays} {LABELS[language].days}
)}
📍
{cities.map((c, idx) => idx > 0 ? <> {transportIcon(c.transport) || "➔"} {c.city}> : c.city )}
🍽️
{cities.map((c, idx) => (idx > 0 ? " ➔ " : "") + foodOptions.filter(f => c.foodChecks && c.foodChecks[f]).join(", ") )}
🏨
{cities.map((c, idx) => (idx > 0 ? " ➔ " : "") + (c.stars ? c.stars + "★ " : "") + (c.accomType || "") )}
🧾
{cities.map((c, idx) => (idx > 0 ? " ➔ " : "") + (c.spend || "") )}
🎟️
{cities.map((c, idx) => (idx > 0 ? " ➔ " : "") + (c.attractions || ""))}
🛫
{cities.filter((c, idx) => idx>0).map((c, idx) => (idx > 0 ? " ➔ " : "") + (transportIcon(c.transport) || "") )}
{LABELS[language].budgetBox}
{formatCurrency(tripTotal, currency)}
{tripDays > 0 ? `${formatCurrency(tripTotal/tripDays, currency)} ${LABELS[language].perDay}` : "--"}
{currency === 'BRL' ? 'R$' : '€'}
{LABELS[language].breakdown}
{breakdown.map(cat =>
{cat.icon}
{LABELS[language][cat.key]}
{formatCurrency(cat.value, currency)}
{breakdownTotal ? (cat.value/breakdownTotal*100).toFixed(1)+"%" : "--"}
)}
Share it:
🌟
Consider adding €{buffer.toFixed(2)} (15%) as a safety buffer for unexpected expenses.
{showProposalForm && (
📋 {LABELS[language].requestProposal}
)}
{showReviewModal && (
{LABELS[language].reviewProposal}
{LABELS[language].fullName} / {LABELS[language].personalID}
{LABELS[language].fullName}: {proposalData.fullName}
{LABELS[language].email}: {proposalData.email}
{LABELS[language].phone}: {proposalData.phone}
{LABELS[language].birthDate}: {proposalData.birthDate ? new Date(proposalData.birthDate).toLocaleDateString() : ''}
{LABELS[language].passportNumber}
{LABELS[language].passportNumber}: {proposalData.passportNumber}
{LABELS[language].issueDate}: {proposalData.issueDate ? new Date(proposalData.issueDate).toLocaleDateString() : ''}
{LABELS[language].expiryDate}: {proposalData.expiryDate ? new Date(proposalData.expiryDate).toLocaleDateString() : ''}
{LABELS[language].issuingAuthority}: {proposalData.issuingAuthority}
{LABELS[language].personalID}: {proposalData.personalID}
{LABELS[language].wiseaccount}
{LABELS[language].wiseaccount}: {proposalData.wiseaccount}
{LABELS[language].personalID}: {proposalData.personalID}
{LABELS[language].tripOverview}
{LABELS[language].tripOverview} {LABELS[language].days}: {startDate ? new Date(startDate).toLocaleDateString() : '--'} {LABELS[language].to} {endDate ? new Date(endDate).toLocaleDateString() : '--'}
{LABELS[language].tripDuration}: {tripDays} {LABELS[language].days}
{LABELS[language].citiesVisited}: {cities.map(c => c.city).join(' → ')}
{LABELS[language].totalBudget}: €{tripTotal.toFixed(2)}
{LABELS[language].perDayAverage}: €{(tripTotal/tripDays).toFixed(2)}
{LABELS[language].breakdown}
🏨 {LABELS[language].accommodation}: €{totalsCat.accommodation.toFixed(2)}
💰 {LABELS[language].transportation}: €{totalsCat.transport.toFixed(2)}
🍽️ {LABELS[language].food}: €{totalsCat.food.toFixed(2)}
🎁 {LABELS[language].extras}: €{totalsCat.extras.toFixed(2)}
{LABELS[language].detailsPerCity}
| {LABELS[language].city} |
{LABELS[language].days} |
{LABELS[language].dailyCost} |
{LABELS[language].totalBudget} |
{cities.map((city, idx) => {
const days = city.startDate && city.endDate ? Math.max(1, Math.round((new Date(city.endDate) - new Date(city.startDate))/(1000*60*60*24))) : 1;
const total = cityTotals[idx] || 0;
const daily = days ? (total/days).toFixed(2) : total.toFixed(2);
return (
| {city.city} |
{days} |
€{daily} |
€{total.toFixed(2)} |
);
})}
{LABELS[language].detailsPerCity}
{cities.map((city, idx) => (
{city.city}
{LABELS[language].tripOverview} {LABELS[language].days}: {city.startDate} to {city.endDate}
{LABELS[language].accommodation}: {city.stars}★ {city.accomType}
{idx > 0 &&
{LABELS[language].transportation}: {city.transport} ({city.transportPrice})
}
{LABELS[language].mealsOptions} {foodOptions.filter(f => city.foodChecks && city.foodChecks[f]).join(', ') || 'None selected'}
{LABELS[language].spendAmount}: {city.spend}
{LABELS[language].attractions}: {city.attractions}
))}
{LABELS[language].reviewProposal} contact@tunvolo.com
)}
>
);
}
// ... ReactDOM line
ReactDOM.createRoot(document.getElementById("app-root")).render();