commit 3cdbaadb9b76c1f12f810077f495e88d9b7f237c Author: Rodribm10 Date: Mon Apr 13 22:55:20 2026 -0300 chore: inicializa repo com POC como referencia diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..263d9b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Build +dist/ +build/ +*.tsbuildinfo + +# Env +.env +.env.local +.env.*.local + +# Editor +.vscode/ +.idea/ +*.swp +.DS_Store + +# Logs +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* + +# Test coverage +coverage/ + +# Supabase +supabase/.branches +supabase/.temp diff --git a/_poc-reference/hotel-1001-noites-prime---reserva/.gitignore b/_poc-reference/hotel-1001-noites-prime---reserva/.gitignore new file mode 100755 index 0000000..a547bf3 --- /dev/null +++ b/_poc-reference/hotel-1001-noites-prime---reserva/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/_poc-reference/hotel-1001-noites-prime---reserva/App.tsx b/_poc-reference/hotel-1001-noites-prime---reserva/App.tsx new file mode 100755 index 0000000..9055e69 --- /dev/null +++ b/_poc-reference/hotel-1001-noites-prime---reserva/App.tsx @@ -0,0 +1,730 @@ + +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()} + + +
+
+ +
+ © {new Date().getFullYear()} {appConfig.title} • Experiência Exclusiva +
+
+ ); +}; + +export default App; diff --git a/_poc-reference/hotel-1001-noites-prime---reserva/README.md b/_poc-reference/hotel-1001-noites-prime---reserva/README.md new file mode 100755 index 0000000..4fa17a2 --- /dev/null +++ b/_poc-reference/hotel-1001-noites-prime---reserva/README.md @@ -0,0 +1,20 @@ +
+GHBanner +
+ +# Run and deploy your AI Studio app + +This contains everything you need to run your app locally. + +View your app in AI Studio: https://ai.studio/apps/3e4d1dea-c81c-483b-8b32-4e3c99790bd4 + +## Run Locally + +**Prerequisites:** Node.js + + +1. Install dependencies: + `npm install` +2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key +3. Run the app: + `npm run dev` diff --git a/_poc-reference/hotel-1001-noites-prime---reserva/components/AdminPage.tsx b/_poc-reference/hotel-1001-noites-prime---reserva/components/AdminPage.tsx new file mode 100755 index 0000000..040cb2f --- /dev/null +++ b/_poc-reference/hotel-1001-noites-prime---reserva/components/AdminPage.tsx @@ -0,0 +1,881 @@ + +import * as React from 'react'; +import { Brand, HotelUnit, Suite, PricingData, SuiteCategoryImage, ExtraItem } from '../types.ts'; +import { brandService } from '../services/brandService.ts'; +import { suiteService } from '../services/suiteService.ts'; +import { hotelUnitService } from '../services/hotelUnitService.ts'; +import { pricingService } from '../services/pricingService.ts'; +import { extraService } from '../services/extraService.ts'; +import { supabase } from '../supabaseClient.ts'; +import FormField from './FormField.tsx'; +import { Button } from './ui/button.tsx'; +import { cn } from '../lib/utils.ts'; +import SelectField from './SelectField.tsx'; +import { motion, AnimatePresence } from 'motion/react'; +import { + Plus, + Edit2, + Trash2, + Save, + X, + LogOut, + LayoutDashboard, + Building2, + Hotel, + BedDouble, + DollarSign, + Sparkles, + Settings, + Loader2, + ChevronLeft, + CheckCircle2, + AlertTriangle +} from 'lucide-react'; + +interface AdminPageProps { + onNavigateToReservation: () => void; + currentConfig: { title: string; subtitle: string }; + onSaveConfig: (config: { title: string; subtitle: string }) => void; +} + +type AdminTab = 'brands' | 'units' | 'suites' | 'prices' | 'extras' | 'settings'; + +const AdminPage: React.FC = ({ onNavigateToReservation, currentConfig, onSaveConfig }) => { + const [isAuthenticated, setIsAuthenticated] = React.useState(false); + const [email, setEmail] = React.useState(''); + const [password, setPassword] = React.useState(''); + const [error, setError] = React.useState(''); + const [activeTab, setActiveTab] = React.useState('brands'); + const [isLoading, setIsLoading] = React.useState(true); // Start as true to check session + const [isSaving, setIsSaving] = React.useState(false); + const [saveStatus, setSaveStatus] = React.useState<{ message: string; type: 'success' | 'error'} | null>(null); + + // Local settings state for the form + const [localSettings, setLocalSettings] = React.useState(currentConfig); + + // Brands state + const [brands, setBrands] = React.useState([]); + const [editingBrand, setEditingBrand] = React.useState(null); + const [currentBrand, setCurrentBrand] = React.useState>>({ name: '', suite_categories: [], stay_durations: [] }); + const [brandFormVisible, setBrandFormVisible] = React.useState(false); + + // Unit state + const [units, setUnits] = React.useState([]); + const [editingUnit, setEditingUnit] = React.useState(null); + const [currentUnit, setCurrentUnit] = React.useState> & { visible_suite_categories?: string[], suite_category_images?: SuiteCategoryImage[] }>({ name: '', brandId: undefined, visible_suite_categories: [], suite_category_images: [] }); + const [unitFormVisible, setUnitFormVisible] = React.useState(false); + + // Suite state + const [suites, setSuites] = React.useState([]); + const [editingSuite, setEditingSuite] = React.useState(null); + const [currentSuite, setCurrentSuite] = React.useState>>({ api_id: undefined, name: '', category: '', unitIds: [] }); + const [suiteFormVisible, setSuiteFormVisible] = React.useState(false); + + // Pricing state + const [selectedBrandForPricing, setSelectedBrandForPricing] = React.useState(''); + const [pricingData, setPricingData] = React.useState(null); + const [isPricingLoading, setIsPricingLoading] = React.useState(false); + + // Extras state + const [extras, setExtras] = React.useState([]); + const [editingExtra, setEditingExtra] = React.useState(null); + const [extraFormVisible, setExtraFormVisible] = React.useState(false); + const [currentExtra, setCurrentExtra] = React.useState>({ + title: '', price: 0, description: '', image: '', category: '', tag: '', active: true, order: 0 + }); + + // Check for active session on mount + React.useEffect(() => { + const checkSession = async () => { + const { data: { session } } = await supabase.auth.getSession(); + if (session) { + setIsAuthenticated(true); + } + setIsLoading(false); // Stop loading after checking session + }; + checkSession(); + }, []); + + const loadData = React.useCallback(async () => { + setIsLoading(true); + try { + const [brandsData, unitsData, suitesData] = await Promise.all([ + brandService.getAllBrands(), + hotelUnitService.getAllUnits(), + suiteService.getAllSuites() + ]); + setBrands(brandsData); + setUnits(unitsData); + setSuites(suitesData); + setExtras(extraService.getExtras().sort((a, b) => a.order - b.order)); // Load extras from LocalStorage + + if (brandsData.length > 0 && !selectedBrandForPricing) { + setSelectedBrandForPricing(String(brandsData[0].id)); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Falha ao carregar dados.'); + } finally { + setIsLoading(false); + } + }, [selectedBrandForPricing]); + + React.useEffect(() => { + if (isAuthenticated) { + loadData(); + } + }, [isAuthenticated, loadData]); + + // Load pricing data when selected brand changes + React.useEffect(() => { + if (selectedBrandForPricing && activeTab === 'prices' && isAuthenticated) { + const brandId = parseInt(selectedBrandForPricing, 10); + const fetchPricing = async () => { + setIsPricingLoading(true); + setPricingData(null); + try { + const data = await pricingService.getPricingData(brandId); + setPricingData(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Falha ao carregar preços.'); + } finally { + setIsPricingLoading(false); + } + }; + fetchPricing(); + } + }, [selectedBrandForPricing, activeTab, isAuthenticated]); + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + setError(''); + try { + const { error } = await supabase.auth.signInWithPassword({ + email: email, + password: password, + }); + if (error) throw error; + setIsAuthenticated(true); + setEmail(''); + setPassword(''); + } catch (err: any) { + setError(err.error_description || err.message || 'E-mail ou senha inválidos.'); + } finally { + setIsLoading(false); + } + }; + + const handleLogout = async () => { + await supabase.auth.signOut(); + setIsAuthenticated(false); + }; + + const showSaveStatus = (message: string, type: 'success' | 'error') => { + setSaveStatus({ message, type }); + setTimeout(() => setSaveStatus(null), 3000); + }; + + const handleSettingsSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSaveConfig(localSettings); + showSaveStatus('Configurações salvas localmente!', 'success'); + }; + + // --- Brand Handlers --- + const handleBrandFormSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!currentBrand.name) { + setError('O nome da marca é obrigatório.'); + return; + } + setIsSaving(true); + setError(''); + try { + const payload = { + name: currentBrand.name, + suite_categories: Array.isArray(currentBrand.suite_categories) ? currentBrand.suite_categories.filter(c => c) : [], + stay_durations: Array.isArray(currentBrand.stay_durations) ? currentBrand.stay_durations.filter(d => d) : [], + }; + if (editingBrand) { + await brandService.updateBrand({ ...editingBrand, ...payload }); + showSaveStatus('Marca atualizada com sucesso!', 'success'); + } else { + await brandService.addBrand(payload); + showSaveStatus('Marca adicionada com sucesso!', 'success'); + } + setBrandFormVisible(false); + setEditingBrand(null); + await loadData(); + } catch(err) { + setError(err instanceof Error ? err.message : 'Erro ao salvar marca.'); + } finally { + setIsSaving(false); + } + }; + const handleEditBrand = (brand: Brand) => { + setEditingBrand(brand); + setCurrentBrand({ + name: brand.name, + suite_categories: brand.suite_categories, + stay_durations: brand.stay_durations, + }); + setBrandFormVisible(true); + setError(''); + }; + const handleDeleteBrand = async (brandId: number) => { + if (window.confirm('Tem certeza? Remover uma marca também removerá suas unidades e preços.')) { + try { + await brandService.deleteBrand(brandId); + showSaveStatus('Marca removida com sucesso.', 'success'); + await loadData(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Erro ao remover marca.'); + } + } + }; + + // --- Unit Handlers --- + const handleCategoryImageURLChange = (category: string, url: string, index: number) => { + setCurrentUnit(prev => { + const updatedImages = [...(prev.suite_category_images || [])]; + let categoryImages = updatedImages.find(ci => ci.category === category); + + if (!categoryImages) { + categoryImages = { category: category, imageUrls: [] }; + updatedImages.push(categoryImages); + } + + const newImageUrls = [...categoryImages.imageUrls]; + newImageUrls[index] = url; + categoryImages.imageUrls = newImageUrls; + + return { ...prev, suite_category_images: updatedImages }; + }); + }; + + const handleUnitFormSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!currentUnit.name || !currentUnit.brandId) { + setError('Nome da unidade e seleção de marca são obrigatórios.'); + return; + } + setIsSaving(true); + setError(''); + try { + const payload = { + name: currentUnit.name, + brandId: currentUnit.brandId, + visible_suite_categories: currentUnit.visible_suite_categories || [], + suite_category_images: currentUnit.suite_category_images || [] + }; + if (editingUnit) { + await hotelUnitService.updateUnit({ ...editingUnit, ...payload }); + showSaveStatus('Unidade atualizada!', 'success'); + } else { + await hotelUnitService.addUnit(payload); + showSaveStatus('Unidade adicionada!', 'success'); + } + setUnitFormVisible(false); + setEditingUnit(null); + await loadData(); + } catch(err) { + setError(err instanceof Error ? err.message : 'Erro ao salvar unidade.'); + } finally { + setIsSaving(false); + } + }; + const handleEditUnit = (unit: HotelUnit) => { + setEditingUnit(unit); + setCurrentUnit({ + name: unit.name, + brandId: unit.brandId, + visible_suite_categories: unit.visible_suite_categories || [], + suite_category_images: unit.suite_category_images || [] + }); + setUnitFormVisible(true); + setError(''); + }; + const handleDeleteUnit = async (unitId: number) => { + if (window.confirm('Tem certeza?')) { + try { + await hotelUnitService.deleteUnit(unitId); + showSaveStatus('Unidade removida.', 'success'); + await loadData(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Erro ao remover unidade.'); + } + } + }; + + const handleCategoryVisibilityChange = (category: string, isChecked: boolean) => { + setCurrentUnit(prev => { + const currentCategories = prev.visible_suite_categories || []; + if (isChecked) { + return { ...prev, visible_suite_categories: [...currentCategories, category] }; + } else { + return { ...prev, visible_suite_categories: currentCategories.filter(c => c !== category) }; + } + }); + }; + + // --- Suite Handlers --- + const handleSuiteUnitAssignmentChange = (unitId: number, isChecked: boolean) => { + setCurrentSuite(prev => { + const currentUnitIds = prev.unitIds || []; + if (isChecked) { + return { ...prev, unitIds: [...currentUnitIds, unitId] }; + } else { + return { ...prev, unitIds: currentUnitIds.filter(id => id !== unitId) }; + } + }); + }; + + const handleSuiteFormSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!currentSuite.api_id || !currentSuite.name || !currentSuite.category) { + setError('Todos os campos da suíte são obrigatórios.'); + return; + } + setIsSaving(true); + setError(''); + try { + const payload = { + api_id: currentSuite.api_id, + name: currentSuite.name, + category: currentSuite.category, + unitIds: currentSuite.unitIds || [] + }; + if (editingSuite) { + await suiteService.updateSuite({ ...editingSuite, ...payload }); + showSaveStatus('Suíte atualizada!', 'success'); + } else { + await suiteService.addSuite(payload); + showSaveStatus('Suíte adicionada!', 'success'); + } + setSuiteFormVisible(false); + setEditingSuite(null); + await loadData(); + } catch(err) { + setError(err instanceof Error ? err.message : 'Erro ao salvar suíte.'); + } finally { + setIsSaving(false); + } + }; + const handleEditSuite = (suite: Suite) => { + setEditingSuite(suite); + setCurrentSuite({ api_id: suite.api_id, name: suite.name, category: suite.category, unitIds: suite.unitIds || [] }); + setSuiteFormVisible(true); + setError(''); + }; + const handleDeleteSuite = async (suiteId: number) => { + if (window.confirm('Tem certeza?')) { + try { + await suiteService.deleteSuite(suiteId); + showSaveStatus('Suíte removida.', 'success'); + await loadData(); + } catch(err) { + setError(err instanceof Error ? err.message : 'Erro ao remover suíte.'); + } + } + }; + + // --- Pricing Handlers --- + const handlePriceChange = (dayRange: string, category: string, duration: string, value: string) => { + setPricingData(prevData => { + if (!prevData) return null; + const newData = JSON.parse(JSON.stringify(prevData)); + const price = parseFloat(value); + newData[dayRange][category][duration] = isNaN(price) ? 0 : price; + return newData; + }); + }; + const handleSavePrices = async () => { + if (pricingData && selectedBrandForPricing) { + setIsSaving(true); + try { + await pricingService.savePricingData(parseInt(selectedBrandForPricing, 10), pricingData); + showSaveStatus('Preços salvos com sucesso!', 'success'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Erro ao salvar preços.'); + } finally { + setIsSaving(false); + } + } + }; + + const renderPricingContent = () => { + const selectedBrand = selectedBrandForPricing ? brands.find(b => b.id === parseInt(selectedBrandForPricing, 10)) : null; + + if (!selectedBrand) { + return

Por favor, selecione uma marca para gerenciar os preços.

; + } + + if (isPricingLoading) { + return

Carregando tabela de preços...

+ } + + if (!pricingData) { + return

Não foi possível carregar os dados de preços. Tente selecionar a marca novamente.

; + } + + if (!selectedBrand.suite_categories || selectedBrand.suite_categories.length === 0 || !selectedBrand.stay_durations || selectedBrand.stay_durations.length === 0) { + return

Para definir os preços, primeiro adicione 'Categorias de Suíte' e 'Tipos de Permanência' na aba de 'Marcas'.

; + } + + + return ( +
+ {Object.entries(pricingData).map(([dayRange, categories]) => ( +
+

{dayRange}

+ {Object.entries(categories).map(([category, durations]) => ( +
+

{category}

+
+ {Object.entries(durations).map(([duration, price]) => ( +
+ + handlePriceChange(dayRange, category, duration, e.target.value)} label="" inputPrefix="R$" /> +
+ ))} +
+
+ ))} +
+ ))} +
+ ); + }; + + // --- Extras Handlers --- + const handleExtraFormSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!currentExtra.title || currentExtra.price === undefined) { + setError('Título e preço são obrigatórios.'); + return; + } + try { + const payload: any = { ...currentExtra }; + if (editingExtra) { + extraService.updateExtra({ ...editingExtra, ...payload }); + showSaveStatus('Item extra atualizado!', 'success'); + } else { + extraService.addExtra(payload); + showSaveStatus('Item extra adicionado!', 'success'); + } + setExtraFormVisible(false); + setEditingExtra(null); + setExtras(extraService.getExtras().sort((a, b) => a.order - b.order)); + } catch (err) { + setError(err instanceof Error ? err.message : 'Erro ao salvar extra.'); + } + }; + + const handleEditExtra = (extra: ExtraItem) => { + setEditingExtra(extra); + setCurrentExtra({ ...extra }); + setExtraFormVisible(true); + setError(''); + }; + + const handleDeleteExtra = (id: string) => { + if (window.confirm('Tem certeza que deseja excluir este item?')) { + extraService.deleteExtra(id); + setExtras(extraService.getExtras().sort((a, b) => a.order - b.order)); + showSaveStatus('Item removido.', 'success'); + } + }; + + const handleToggleExtraStatus = (id: string) => { + extraService.toggleStatus(id); + setExtras(extraService.getExtras().sort((a, b) => a.order - b.order)); + }; + + + // --- Login/Logout --- + if (isLoading) { + return ( +
+

Verificando sessão...

+
+ ); + } + + if (!isAuthenticated) { + return ( +
+
+

Login Administrativo

+
+ setEmail(e.target.value)} required autoComplete="email" /> + setPassword(e.target.value)} required autoComplete="current-password" /> + {error &&

{error}

} + + + +
+
+ ); + } + + const brandOptions = brands.map(b => ({ value: String(b.id), label: b.name })); + + // --- RENDER --- + return ( +
+
+
+
+
+ +
+
+

Painel Administrativo

+

Gerencie marcas, unidades e reservas

+
+
+
+ + +
+
+ +
+ }>Marcas + }>Unidades + }>Suítes + }>Preços + }>Extras & Pacotes + }>Configurações +
+ + {error &&
{error}
} + {saveStatus &&
{saveStatus.message}
} + + {isLoading ? ( +
+ +

Carregando dados do sistema...

+
+ ) : ( + + + {/* Brands Tab */} +
+
+

Gerenciar Marcas

+ {!brandFormVisible && } + {brandFormVisible && ( +
+

{editingBrand ? 'Editar Marca' : 'Nova Marca'}

+ setCurrentBrand(p => ({...p, name: e.target.value}))} required /> + setCurrentBrand(p => ({...p, suite_categories: e.target.value.split(',').map(s => s.trim())}))} /> + setCurrentBrand(p => ({...p, stay_durations: e.target.value.split(',').map(s => s.trim())}))} /> + +
+ + +
+ + )} + ({ + id: b.id, + cells: [ + b.name, + b.suite_categories.join(', '), + b.stay_durations.join(', '), + handleEditBrand(b)} onDelete={() => handleDeleteBrand(b.id)} /> + ] + }))} + /> +
+
+ + {/* Units Tab */} +
+
+

Gerenciar Unidades

+ {!unitFormVisible && } + {brands.length === 0 &&

Crie uma marca antes de adicionar unidades.

} + {unitFormVisible && ( +
+

{editingUnit ? 'Editar Unidade' : 'Nova Unidade'}

+ setCurrentUnit(p => ({...p, brandId: parseInt(e.target.value, 10), visible_suite_categories: [] }))} options={brandOptions} required /> + setCurrentUnit(p => ({...p, name: e.target.value}))} required /> + + {currentUnit.brandId && ( +
+

Categorias e Imagens

+

Selecione as categorias visíveis e cole as URLs públicas das imagens (do Backblaze B2).

+
+ {(brands.find(b => b.id === currentUnit.brandId)?.suite_categories || []).map(category => { + const imageInfo = currentUnit.suite_category_images?.find(ci => ci.category === category); + return ( +
+
+ handleCategoryVisibilityChange(category, e.target.checked)} className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500" /> + +
+
+ handleCategoryImageURLChange(category, e.target.value, 0)} placeholder="https://.../imagem1.jpg" /> + handleCategoryImageURLChange(category, e.target.value, 1)} placeholder="https://.../imagem2.jpg" /> +
+
+ )})} +
+
+ )} + +
+ + +
+ + )} + ({ + id: u.id, + cells: [ + u.name, + brands.find(b=>b.id === u.brandId)?.name || 'N/A', + (u.visible_suite_categories || []).join(', '), + handleEditUnit(u)} onDelete={() => handleDeleteUnit(u.id)} /> + ] + }))} + /> +
+
+ + {/* Suites Tab */} +
+
+

Gerenciar Suítes

+ {!suiteFormVisible && } + {suiteFormVisible && ( +
+

{editingSuite ? 'Editar Suíte' : 'Nova Suíte'}

+ setCurrentSuite(p => ({...p, api_id: parseInt(e.target.value, 10)}))} required /> + setCurrentSuite(p => ({...p, name: e.target.value}))} required /> + setCurrentSuite(p => ({...p, category: e.target.value}))} required instruction="A categoria deve corresponder a uma das categorias cadastradas na marca." /> + +
+ +
+ {units.length > 0 ? units.map(unit => ( +
+ handleSuiteUnitAssignmentChange(unit.id, e.target.checked)} + className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500" /> + +
+ )) :

Nenhuma unidade cadastrada. Crie unidades na aba 'Unidades'.

} +
+

Marque as unidades onde esta suíte estará disponível.

+
+ +
+ + +
+ + )} + ({ + id: s.id, + cells: [ + s.api_id, + s.name, + s.category, + s.unitIds?.map(unitId => units.find(u => u.id === unitId)?.name).filter(Boolean).join(', ') || 'Nenhuma', + handleEditSuite(s)} onDelete={() => handleDeleteSuite(s.id)} /> + ] + }))} + /> +
+
+ + {/* Pricing Tab */} +
+
+
+

Tabela de Preços

+ +
+ setSelectedBrandForPricing(e.target.value)} options={brandOptions} /> + {renderPricingContent()} +
+
+ + {/* Extras Tab */} +
+
+

Catálogo de Extras & Pacotes

+ {!extraFormVisible && } + + {extraFormVisible && ( +
+

{editingExtra ? 'Editar Extra' : 'Novo Extra'}

+
+ setCurrentExtra(p => ({...p, title: e.target.value}))} required /> + setCurrentExtra(p => ({...p, price: parseFloat(e.target.value)}))} required /> +
+ setCurrentExtra(p => ({...p, description: e.target.value}))} fieldType="textarea" rows={2} /> + setCurrentExtra(p => ({...p, image: e.target.value}))} placeholder="https://..." /> + +
+ setCurrentExtra(p => ({...p, category: e.target.value}))} /> + setCurrentExtra(p => ({...p, tag: e.target.value}))} /> + setCurrentExtra(p => ({...p, order: parseInt(e.target.value)}))} /> +
+ +
+ setCurrentExtra(p => ({...p, active: e.target.checked}))} + className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500" + /> + +
+ +
+ + +
+ + )} + + ({ + id: e.id, + cells: [ + e.image ? {e.title} : 'Sem img', + e.title, + e.category || '-', + `R$ ${e.price.toFixed(2)}`, + e.order || 0, + {e.active ? 'Ativo' : 'Inativo'}, +
+ + + +
+ ] + }))} + /> +
+
+ + {/* Settings Tab */} +
+
+

Configurações Gerais

+

+ Nota: Essas alterações são salvas localmente neste navegador. Para uma aplicação em produção, estes dados deveriam ser salvos no banco de dados. +

+
+ setLocalSettings(prev => ({ ...prev, title: e.target.value }))} + placeholder="Ex: Reserva Premium" + required + /> + setLocalSettings(prev => ({ ...prev, subtitle: e.target.value }))} + placeholder="Ex: Hotel 1001 Noites Prime" + required + /> + + +
+
+
+
+ )} +
+
+ ); +}; + + +const TabButton: React.FC<{ + tabId: AdminTab; + activeTab: AdminTab; + children: React.ReactNode; + onClick: (tabId: AdminTab) => void; + icon?: React.ReactNode; +}> = ({ tabId, activeTab, children, onClick, icon }) => ( + +); + +interface DataRow { + id: string | number; + cells: (string | number | React.ReactNode)[]; +} + +const DataTable: React.FC<{ headers: string[]; data: DataRow[]}> = ({ headers, data }) => ( +
+ + + {headers.map(h => )} + + + {data.length === 0 ? : + data.map(row => {row.cells.map((cell, j) => )})} + +
{h}
Nenhum dado encontrado.
{cell}
+
+); + +const ActionButtons: React.FC<{ onEdit: () => void; onDelete: () => void }> = ({ onEdit, onDelete }) => ( +
+ + +
+); + + +export default AdminPage; diff --git a/_poc-reference/hotel-1001-noites-prime---reserva/components/Button.tsx b/_poc-reference/hotel-1001-noites-prime---reserva/components/Button.tsx new file mode 100644 index 0000000..e69de29 diff --git a/_poc-reference/hotel-1001-noites-prime---reserva/components/FormField.tsx b/_poc-reference/hotel-1001-noites-prime---reserva/components/FormField.tsx new file mode 100755 index 0000000..a9e00e2 --- /dev/null +++ b/_poc-reference/hotel-1001-noites-prime---reserva/components/FormField.tsx @@ -0,0 +1,117 @@ + + +import * as React from 'react'; +import { cn } from '../lib/utils.ts'; + +interface FormFieldProps { + id: string; + name: string; + label: string; + type?: string; // 'text', 'email', 'datetime-local', etc. For input elements + value: string; + onChange: (e: React.ChangeEvent) => void; + required?: boolean; + placeholder?: string; + instruction?: string; + fieldType?: 'input' | 'textarea'; + rows?: number; // for textarea + autoComplete?: string; + inputPrefix?: string; // New prop for the prefix + error?: boolean; // New prop for validation state + icon?: React.ReactNode; // New prop for icon +} + +const FormField: React.FC = ({ + id, + name, + label, + type = 'text', + value, + onChange, + required = false, + placeholder = '', + instruction, + fieldType = 'input', + rows = 4, + autoComplete, + inputPrefix, + error = false, + icon, +}) => { + const dateTimeStyles = type.includes('date') || type.includes('time') ? { colorScheme: 'light' } : {}; + + const renderInput = () => { + const finalInputClasses = cn( + "block w-full px-4 py-3.5 bg-[#F8FAFC] border-[1.5px] rounded-xl text-sm font-medium text-[#1B3B5F] placeholder-[#9CA3AF]", + "transition-all duration-300 ease-out", + "focus:outline-none focus:border-[#1E90FF] focus:bg-white focus:ring-4 focus:ring-[#1E90FF]/10", + "disabled:bg-slate-100 disabled:text-slate-400 disabled:border-slate-200 disabled:cursor-not-allowed", + inputPrefix ? 'rounded-l-none border-l-0' : '', + icon ? 'pl-11' : '', + "relative min-w-0 flex-1 hover:border-[#1B3B5F]/50", + error + ? "border-red-500 text-red-900 placeholder-red-400 focus:border-red-500 focus:ring-red-500/10" + : "border-[#1B3B5F]/20" + ); + + if (fieldType === 'textarea') { + return ( +