import * as React from 'react'; import confetti from 'canvas-confetti'; import { motion, AnimatePresence } from 'motion/react'; import { CheckCircle2, AlertTriangle, Copy, Check, ArrowLeft, Settings, Loader2, Calendar, User, Phone, Mail, FileText, Info, Building2, Hotel, Clock, BedDouble } from 'lucide-react'; import FormField from './components/FormField.tsx'; import SelectField from './components/SelectField.tsx'; import { Button } from './components/ui/button.tsx'; import AdminPage from './components/AdminPage.tsx'; import { FormDataModel, ApiPostPayload, SubmissionState, N8nApiResponse, Brand, HotelUnit, ExtraItem } from './types.ts'; import { submitReservation, checkPaymentStatus } from './services/apiService.ts'; import { suiteService } from './services/suiteService.ts'; import { hotelUnitService } from './services/hotelUnitService.ts'; import { brandService } from './services/brandService.ts'; import { pricingService } from './services/pricingService.ts'; import { extraService } from './services/extraService.ts'; const MODO_FIXED = 0; type SelectOption = { value: string; label: string }; const App: React.FC = () => { const [currentView, setCurrentView] = React.useState<'reservation' | 'admin'>('reservation'); const [view, setView] = React.useState<'form' | 'payment' | 'success' | 'expired'>('form'); const [isDataLoading, setIsDataLoading] = React.useState(true); // Configuração de Título e Subtítulo (Persistência Local) const [appConfig, setAppConfig] = React.useState({ title: 'Reserva Premium', subtitle: 'Hotel 1001 Noites Prime' }); React.useEffect(() => { const savedConfig = localStorage.getItem('hotelAppConfig'); if (savedConfig) { try { setAppConfig(JSON.parse(savedConfig)); } catch (e) { console.error("Erro ao carregar configurações", e); } } }, []); const handleSaveConfig = (newConfig: { title: string; subtitle: string }) => { setAppConfig(newConfig); localStorage.setItem('hotelAppConfig', JSON.stringify(newConfig)); }; const initialFormData: FormDataModel = { nome: '', checkInDateTime: '', telefone: '', email: '', cpf: '', observacao: '', selectedBrand: '', selectedUnit: '', selectedCategory: '', stayDuration: '', selectedExtras: [], }; const [formData, setFormData] = React.useState(initialFormData); const [isLoading, setIsLoading] = React.useState(false); const [submissionStatus, setSubmissionStatus] = React.useState(null); const [formErrors, setFormErrors] = React.useState>>({}); const [brands, setBrands] = React.useState([]); const [units, setUnits] = React.useState([]); const [brandOptions, setBrandOptions] = React.useState([]); const [unitOptions, setUnitOptions] = React.useState([]); const [categoryOptions, setCategoryOptions] = React.useState([]); const [durationOptions, setDurationOptions] = React.useState([]); const [isCopied, setIsCopied] = React.useState(false); const [timeLeft, setTimeLeft] = React.useState(10 * 60); const [selectedCategoryImageUrls, setSelectedCategoryImageUrls] = React.useState(null); const [calculatedPrice, setCalculatedPrice] = React.useState(null); const [basePrice, setBasePrice] = React.useState(null); const [isPriceLoading, setIsPriceLoading] = React.useState(false); // Extras state const [availableExtras, setAvailableExtras] = React.useState([]); const txid = submissionStatus?.pix?.txid; // Extract txid for cleaner dependency management React.useEffect(() => { if (view !== 'payment' || !txid) return; const pollInterval = setInterval(async () => { try { const result = await checkPaymentStatus(txid); if (result?.status?.trim().toLowerCase() === 'pago') { clearInterval(pollInterval); setView('success'); triggerFireworks(); } } catch (err) { console.error("Erro ao verificar pagamento:", err); } }, 10000); // A cada 10 segundos return () => clearInterval(pollInterval); }, [txid, view]); React.useEffect(() => { if (view !== 'payment') return; setTimeLeft(10 * 60); const timer = setInterval(() => { setTimeLeft(prev => { if (prev <= 1) { clearInterval(timer); setView('expired'); return 0; } return prev - 1; }); }, 1000); // A cada 1 segundo return () => clearInterval(timer); }, [view]); const triggerFireworks = () => { const duration = 3 * 1000; const animationEnd = Date.now() + duration; const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 100 }; const randomInRange = (min: number, max: number) => Math.random() * (max - min) + min; const interval = window.setInterval(() => { const timeLeft = animationEnd - Date.now(); if (timeLeft <= 0) return clearInterval(interval); const particleCount = 50 * (timeLeft / duration); confetti({ ...defaults, particleCount, origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 } }); confetti({ ...defaults, particleCount, origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 } }); }, 250); }; const formatTime = (seconds: number) => { const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; return `${String(minutes).padStart(2, '0')}:${String(remainingSeconds).padStart(2, '0')}`; }; const loadInitialData = React.useCallback(async () => { setIsDataLoading(true); try { const allBrands = await brandService.getAllBrands(); const allUnits = await hotelUnitService.getAllUnits(); setBrands(allBrands); setUnits(allUnits); setBrandOptions(allBrands.map(b => ({ value: String(b.id), label: b.name }))); const extras = extraService.getExtras(); setAvailableExtras(extras.filter(e => e.active).sort((a, b) => a.order - b.order)); } catch (error) { console.error("Failed to load initial data:", error); } finally { setIsDataLoading(false); } }, []); React.useEffect(() => { if (currentView === 'reservation') { loadInitialData(); } }, [currentView, loadInitialData]); // Atualiza Unidades e Tipos de Permanência quando a Marca muda React.useEffect(() => { if (formData.selectedBrand) { const brandId = parseInt(formData.selectedBrand, 10); const selectedBrand = brands.find(b => b.id === brandId); if (selectedBrand) { setUnitOptions(units.filter(u => u.brandId === brandId).map(u => ({ value: String(u.id), label: u.name }))); setDurationOptions(selectedBrand.stay_durations.map(d => ({ value: d, label: d }))); } } else { setUnitOptions([]); setCategoryOptions([]); setDurationOptions([]); } }, [formData.selectedBrand, brands, units]); // Atualiza Categorias de Suíte quando a Unidade muda React.useEffect(() => { if (formData.selectedUnit) { const unitId = parseInt(formData.selectedUnit, 10); const selectedUnit = units.find(u => u.id === unitId); if (selectedUnit?.visible_suite_categories) { setCategoryOptions(selectedUnit.visible_suite_categories.map(c => ({ value: c, label: c }))); } else { setCategoryOptions([]); // Limpa se a unidade não tem categorias visíveis configuradas } } else { setCategoryOptions([]); // Limpa se nenhuma unidade está selecionada } }, [formData.selectedUnit, units]); // Atualiza a imagem da categoria quando a categoria ou unidade muda React.useEffect(() => { if (formData.selectedUnit && formData.selectedCategory) { const unit = units.find(u => String(u.id) === formData.selectedUnit); const imageInfo = unit?.suite_category_images?.find(img => img.category === formData.selectedCategory); setSelectedCategoryImageUrls(imageInfo?.imageUrls || null); } else { setSelectedCategoryImageUrls(null); } }, [formData.selectedCategory, formData.selectedUnit, units]); // Efeito para calcular o preço dinamicamente React.useEffect(() => { const calculatePrice = async () => { if (formData.selectedBrand && formData.selectedCategory && formData.stayDuration && formData.checkInDateTime) { setIsPriceLoading(true); setBasePrice(null); setCalculatedPrice(null); try { const pricingData = await pricingService.getPricingData(parseInt(formData.selectedBrand, 10)); const dayOfWeek = new Date(formData.checkInDateTime).getDay(); const dayRange = (dayOfWeek >= 1 && dayOfWeek <= 3) ? "SEGUNDA A QUARTA" : "QUINTA A DOMINGO"; const price = pricingData?.[dayRange]?.[formData.selectedCategory]?.[formData.stayDuration]; if (price !== undefined && price > 0) { setBasePrice(price); } else { setBasePrice(null); } } catch (error) { console.error("Error calculating price:", error); setBasePrice(null); } finally { setIsPriceLoading(false); } } else { setBasePrice(null); } }; const debounceTimer = setTimeout(() => { calculatePrice(); }, 300); // Debounce to avoid rapid recalculations return () => clearTimeout(debounceTimer); }, [formData.selectedBrand, formData.selectedCategory, formData.stayDuration, formData.checkInDateTime]); // Recalculate total price when Extras change or Base price changes React.useEffect(() => { if (basePrice !== null) { const extrasTotal = formData.selectedExtras.reduce((sum, extraId) => { const extra = availableExtras.find(e => e.id === extraId); return sum + (extra ? extra.price : 0); }, 0); setCalculatedPrice(basePrice + extrasTotal); } else { setCalculatedPrice(null); } }, [basePrice, formData.selectedExtras, availableExtras]); const validateForm = (): boolean => { const errors: Partial> = {}; if (!formData.nome.trim()) errors.nome = "Nome completo é obrigatório."; if (!formData.checkInDateTime) errors.checkInDateTime = "Data e horário do check-in são obrigatórios."; else if (new Date(formData.checkInDateTime) <= new Date()) { errors.checkInDateTime = "A data de check-in deve ser futura."; } const cleanedTelefone = formData.telefone.replace(/\D/g, ''); if (!cleanedTelefone || !/^\d{10,11}$/.test(cleanedTelefone)) { errors.telefone = "Telefone inválido (DDD + 8 ou 9 dígitos)."; } if (!formData.email.trim() || !/\S+@\S+\.\S+/.test(formData.email)) errors.email = "Formato de e-mail inválido."; const cleanedCpf = formData.cpf.replace(/\D/g, ''); if (!formData.cpf.trim() || !/^\d{11}$/.test(cleanedCpf)) errors.cpf = "CPF inválido (11 dígitos)."; if (!formData.selectedBrand) errors.selectedBrand = "Seleção de marca é obrigatória."; if (!formData.selectedUnit) errors.selectedUnit = "Seleção de unidade é obrigatória."; if (!formData.selectedCategory) errors.selectedCategory = "Seleção de categoria é obrigatória."; if (!formData.stayDuration) errors.stayDuration = "Seleção do tipo de permanência é obrigatória."; setFormErrors(errors); return Object.keys(errors).length === 0; }; const handleTelefoneChange = (value: string): string => { return value.replace(/\D/g, '').replace(/(\d{2})(\d{1,5})(\d{4})/, '($1)$2-$3'); }; const handleChange = React.useCallback((e: React.ChangeEvent) => { const { name, value } = e.target; if (name === 'selectedBrand') { setFormData(prev => ({ ...prev, selectedBrand: value, selectedUnit: '', selectedCategory: '', stayDuration: '' })); } else if (name === 'selectedUnit') { setFormData(prev => ({ ...prev, selectedUnit: value, selectedCategory: '' })); } else if (name === 'telefone') { setFormData(prev => ({ ...prev, telefone: handleTelefoneChange(value) })); } else { setFormData(prev => ({ ...prev, [name]: value })); } if (formErrors[name as keyof FormDataModel]) { setFormErrors(prev => ({ ...prev, [name]: undefined })); } }, [formErrors]); const toggleExtra = (extra: ExtraItem) => { setFormData(prev => { const isSelected = prev.selectedExtras.includes(extra.id); const newExtras = isSelected ? prev.selectedExtras.filter(id => id !== extra.id) : [...prev.selectedExtras, extra.id]; return { ...prev, selectedExtras: newExtras }; }); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setSubmissionStatus(null); if (!validateForm()) { setSubmissionStatus({ message: "Por favor, corrija os campos destacados.", type: 'error' }); return; } if (calculatedPrice === null || calculatedPrice <= 0) { setSubmissionStatus({ message: "Não foi possível calcular o preço. Verifique se todas as opções de reserva estão preenchidas corretamente.", type: 'error' }); return; } setIsLoading(true); try { const price = calculatedPrice; const selectedUnitId = parseInt(formData.selectedUnit, 10); if (isNaN(selectedUnitId)) { throw new Error("Unidade do hotel inválida selecionada."); } const selectedSuiteApiId = await suiteService.getRandomSuiteApiIdFromCategory(formData.selectedCategory, selectedUnitId); if (selectedSuiteApiId === null) { throw new Error("Nenhuma suíte disponível para a categoria e unidade selecionadas."); } const cleanedCpf = formData.cpf.replace(/\D/g, ''); const cleanedTelefone = formData.telefone.replace(/\D/g, ''); const integracaoId = `reserva-${cleanedCpf.substring(0, 9)}-${Date.now().toString().slice(-6)}`; const selectedBrandName = brands.find(b => b.id === parseInt(formData.selectedBrand, 10))?.name || ''; const selectedUnitName = units.find(u => u.id === parseInt(formData.selectedUnit, 10))?.name || ''; const selectedExtrasObjects = availableExtras.filter(e => formData.selectedExtras.includes(e.id)); const apiPayload: ApiPostPayload = { suite_id: selectedSuiteApiId, data_inicio: new Date(formData.checkInDateTime).toISOString(), nome: formData.nome, telefone: cleanedTelefone, email: formData.email, cpf: cleanedCpf, integracao_id: integracaoId, modo: MODO_FIXED, marca: selectedBrandName, unidade: selectedUnitName, categoria: formData.selectedCategory, permanencia: formData.stayDuration, valor: price / 2, // Envia apenas 50% do valor para o pagamento PIX observacoes: formData.observacao.trim(), extras_selecionados: selectedExtrasObjects }; const apiResponse = await submitReservation(apiPayload); setSubmissionStatus({ message: 'Sua reserva foi iniciada! Realize o pagamento via Pix para confirmar.', type: 'success', pix: { qrCodeValue: apiResponse.pixUrl, copyPasteCode: apiResponse.pixCopiaECola, txid: apiResponse.txid, } }); setView('payment'); } catch (error) { const detailedErrorMessage = error instanceof Error ? error.message : "Houve um problema desconhecido."; setSubmissionStatus({ message: `Falha na solicitação: ${detailedErrorMessage}`, type: 'error' }); console.error("API Submission Error:", error); } finally { setIsLoading(false); } }; const handleCopyPix = () => { if (submissionStatus?.pix?.copyPasteCode) { navigator.clipboard.writeText(submissionStatus.pix.copyPasteCode); setIsCopied(true); setTimeout(() => setIsCopied(false), 2000); } }; const handleResetForm = () => { setFormData(initialFormData); setSubmissionStatus(null); setFormErrors({}); setIsLoading(false); setView('form'); }; const handleNavigateToReservation = () => { setCurrentView('reservation'); }; if (currentView === 'admin') { return ( ); } const renderContent = () => { switch (view) { case 'success': return (

Pagamento Confirmado!

Sua reserva está 100% garantida.
Enviamos os detalhes para o seu e-mail.

); case 'expired': return (

Tempo Esgotado

O tempo para realizar o pagamento expirou.
Por favor, inicie uma nova reserva.

); case 'payment': return (

{submissionStatus?.message}

Tempo Restante

{formatTime(timeLeft)}
Restam apenas 3 suítes disponíveis — garanta a sua!
); case 'form': default: return ( <>
{isDataLoading ? (

Carregando dados...

) : (

Detalhes da Estadia

} />
} /> } />
} /> } />
{selectedCategoryImageUrls && selectedCategoryImageUrls.length > 0 && (
{selectedCategoryImageUrls.map((url, index) => ( url && (
{`Imagem ) ))}
)} {/* Seção de Extras */} {basePrice !== null && availableExtras.length > 0 && (

Adicione algo especial à sua experiência

{availableExtras.map(extra => { const isSelected = formData.selectedExtras.includes(extra.id); return (
toggleExtra(extra)} className={` cursor-pointer transition-all duration-200 p-4 rounded-2xl shadow-md border ${isSelected ? 'border-[#1E90FF] shadow-[0_0_15px_#1E90FF55]' : 'border-[#1B3B5F]'} bg-[#0A1A2F]/80 backdrop-blur-sm hover:scale-[1.02] hover:border-[#1E90FF] `} > {extra.image && {extra.title}}

{extra.title}

{extra.tag && ( {extra.tag} )}

{extra.description}

{new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(extra.price)}
); })}
)}
{isPriceLoading && (

Calculando valor...

)} {!isPriceLoading && calculatedPrice !== null && (
PREÇO ESTIMADO
Valor Total da Reserva {new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(calculatedPrice)}
{formData.selectedExtras.length > 0 && (
(Suíte + Extras)
)}
Pagar no check-in {new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(calculatedPrice / 2)}

Entrada via Pix (50%)

Necessário para confirmar

{new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(calculatedPrice / 2)}
)}

Seus Dados

} />
} /> } />
} />
{submissionStatus?.type === 'error' && ( {submissionStatus.message} )} )} ); } }; return (
{/* Decorative Top Accent */}

{view === 'payment' ? 'Pagamento Seguro' : view === 'success' ? 'Reserva Confirmada' : view === 'expired' ? 'Tempo Esgotado' : appConfig.title}

{view === 'form' &&

{appConfig.subtitle}

}
{renderContent()}
); }; export default App;