chore: inicializa repo com POC como referencia

This commit is contained in:
Rodribm10 2026-04-13 22:55:20 -03:00
commit 3cdbaadb9b
28 changed files with 6192 additions and 0 deletions

32
.gitignore vendored Normal file
View File

@ -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

View File

@ -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?

View File

@ -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<FormDataModel>(initialFormData);
const [isLoading, setIsLoading] = React.useState<boolean>(false);
const [submissionStatus, setSubmissionStatus] = React.useState<SubmissionState | null>(null);
const [formErrors, setFormErrors] = React.useState<Partial<Record<keyof FormDataModel, string>>>({});
const [brands, setBrands] = React.useState<Brand[]>([]);
const [units, setUnits] = React.useState<HotelUnit[]>([]);
const [brandOptions, setBrandOptions] = React.useState<SelectOption[]>([]);
const [unitOptions, setUnitOptions] = React.useState<SelectOption[]>([]);
const [categoryOptions, setCategoryOptions] = React.useState<SelectOption[]>([]);
const [durationOptions, setDurationOptions] = React.useState<SelectOption[]>([]);
const [isCopied, setIsCopied] = React.useState(false);
const [timeLeft, setTimeLeft] = React.useState(10 * 60);
const [selectedCategoryImageUrls, setSelectedCategoryImageUrls] = React.useState<string[] | null>(null);
const [calculatedPrice, setCalculatedPrice] = React.useState<number | null>(null);
const [basePrice, setBasePrice] = React.useState<number | null>(null);
const [isPriceLoading, setIsPriceLoading] = React.useState<boolean>(false);
// Extras state
const [availableExtras, setAvailableExtras] = React.useState<ExtraItem[]>([]);
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<Record<keyof FormDataModel, string>> = {};
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<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
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<HTMLFormElement>) => {
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 (
<AdminPage
onNavigateToReservation={handleNavigateToReservation}
currentConfig={appConfig}
onSaveConfig={handleSaveConfig}
/>
);
}
const renderContent = () => {
switch (view) {
case 'success':
return (
<div className="text-center space-y-6 p-10 bg-[#F8FAFC] border border-[#1B3B5F]/10 rounded-3xl shadow-inner">
<div className="mx-auto w-24 h-24 bg-green-100 rounded-full flex items-center justify-center mb-6 shadow-md">
<CheckCircle2 className="h-12 w-12 text-green-600" />
</div>
<h2 className="text-3xl font-extrabold text-[#1B3B5F]">Pagamento Confirmado!</h2>
<p className="text-[#9CA3AF] text-lg">Sua reserva está 100% garantida.<br/>Enviamos os detalhes para o seu e-mail.</p>
<div className="pt-6">
<Button onClick={handleResetForm} variant="outline" className="w-full">
Fazer Nova Reserva
</Button>
</div>
</div>
);
case 'expired':
return (
<div className="text-center space-y-6 p-10 bg-red-50 border border-red-100 rounded-3xl shadow-inner">
<div className="mx-auto w-24 h-24 bg-red-100 rounded-full flex items-center justify-center mb-6 shadow-md">
<AlertTriangle className="h-12 w-12 text-red-600" />
</div>
<h2 className="text-3xl font-extrabold text-[#1B3B5F]">Tempo Esgotado</h2>
<p className="text-[#9CA3AF] text-lg">O tempo para realizar o pagamento expirou.<br/>Por favor, inicie uma nova reserva.</p>
<div className="pt-6">
<Button onClick={handleResetForm} variant="outline" className="w-full">
Tentar Novamente
</Button>
</div>
</div>
);
case 'payment':
return (
<div className="text-center space-y-6">
<div className="p-4 bg-[#F8FAFC] rounded-2xl border border-[#1B3B5F]/10 mb-6">
<p className="text-[#1B3B5F] font-medium">{submissionStatus?.message}</p>
</div>
<div className="flex flex-col items-center justify-center space-y-2">
<p className="text-xs font-bold text-[#9CA3AF] uppercase tracking-widest">Tempo Restante</p>
<div className="text-4xl font-extrabold text-red-500 tabular-nums tracking-tight">
{formatTime(timeLeft)}
</div>
</div>
<div className="bg-amber-50 border border-amber-200 p-4 rounded-xl text-amber-800 text-sm flex items-center justify-center gap-2 shadow-sm">
<Info className="w-5 h-5 text-amber-500" />
<span>Restam apenas <strong className="font-bold">3 suítes</strong> disponíveis garanta a sua!</span>
</div>
<div className="pt-4 pb-2 text-left">
<label className="block text-xs font-bold text-[#1B3B5F] uppercase tracking-wide mb-2">Código Pix Copia e Cola</label>
<div className="relative group">
<input
type="text"
readOnly
value={submissionStatus?.pix?.copyPasteCode || ''}
className="w-full bg-[#F8FAFC] border-[1.5px] border-[#1B3B5F]/20 rounded-xl p-4 pr-28 text-sm text-[#1B3B5F] font-mono focus:outline-none focus:border-[#1E90FF] focus:ring-2 focus:ring-[#1E90FF]/10 transition-all"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<Button onClick={handleCopyPix} size="sm" variant={isCopied ? "default" : "secondary"} className="flex items-center gap-2">
{isCopied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
{isCopied ? 'Copiado!' : 'Copiar'}
</Button>
</div>
</div>
</div>
<div className="pt-4 border-t border-[#1B3B5F]/10">
<Button onClick={handleResetForm} variant="ghost" className="w-full text-[#9CA3AF] hover:text-[#1B3B5F] flex items-center justify-center gap-2">
<ArrowLeft className="w-4 h-4" />
Cancelar e Voltar
</Button>
</div>
</div>
);
case 'form':
default:
return (
<>
<div className="sm:hidden flex justify-center mb-8">
<Button onClick={() => setCurrentView('admin')} variant="secondary" size="sm" className="rounded-full px-6 flex items-center gap-2">
<Settings className="w-4 h-4" />
Painel Admin
</Button>
</div>
{isDataLoading ? (
<div className="text-center py-20 flex flex-col items-center justify-center space-y-4">
<Loader2 className="w-8 h-8 text-[#1E90FF] animate-spin" />
<p className="text-[#9CA3AF] font-medium animate-pulse">Carregando dados...</p>
</div>
) : (
<form onSubmit={handleSubmit} noValidate className="space-y-2">
<div className="bg-[#F8FAFC] p-6 rounded-2xl border border-[#1B3B5F]/10 mb-8 shadow-sm">
<h3 className="text-[#1B3B5F] font-bold text-sm uppercase tracking-wider mb-4 border-b border-[#1B3B5F]/10 pb-2 flex items-center gap-2">
<Calendar className="w-4 h-4" />
Detalhes da Estadia
</h3>
<div className="grid grid-cols-1 gap-1">
<SelectField id="selectedBrand" name="selectedBrand" label="Marca" value={formData.selectedBrand} onChange={handleChange} options={brandOptions} required placeholder="Selecione a marca" disabled={brandOptions.length === 0} instruction={brandOptions.length === 0 ? "Nenhuma marca disponível." : ""} error={!!formErrors.selectedBrand} icon={<Building2 className="w-4 h-4" />} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<SelectField id="selectedUnit" name="selectedUnit" label="Unidade do Hotel" value={formData.selectedUnit} onChange={handleChange} options={unitOptions} required placeholder="Selecione a unidade" disabled={!formData.selectedBrand || unitOptions.length === 0} instruction={!!formData.selectedBrand && unitOptions.length === 0 ? "Nenhuma unidade para esta marca." : ""} error={!!formErrors.selectedUnit} icon={<Hotel className="w-4 h-4" />} />
<SelectField id="stayDuration" name="stayDuration" label="Permanência" value={formData.stayDuration} onChange={handleChange} options={durationOptions} required placeholder="Selecione o tempo" disabled={!formData.selectedBrand} error={!!formErrors.stayDuration} icon={<Clock className="w-4 h-4" />} />
</div>
<SelectField id="selectedCategory" name="selectedCategory" label="Categoria da Suíte" value={formData.selectedCategory} onChange={handleChange} options={categoryOptions} required placeholder="Selecione a categoria" disabled={!formData.selectedUnit || categoryOptions.length === 0} instruction={!!formData.selectedUnit && categoryOptions.length === 0 ? "Nenhuma categoria disponível para esta unidade." : ""} error={!!formErrors.selectedCategory} icon={<BedDouble className="w-4 h-4" />} />
<FormField id="checkInDateTime" name="checkInDateTime" label="Data e Horário do Check-in" type="datetime-local" value={formData.checkInDateTime} onChange={handleChange} required error={!!formErrors.checkInDateTime} icon={<Calendar className="w-4 h-4" />} />
</div>
</div>
{selectedCategoryImageUrls && selectedCategoryImageUrls.length > 0 && (
<div className="mb-8 animate-fade-in grid grid-cols-1 sm:grid-cols-2 gap-4">
{selectedCategoryImageUrls.map((url, index) => (
url && (
<a href={url} target="_blank" rel="noopener noreferrer" key={index} className="block group relative overflow-hidden rounded-2xl shadow-md hover:shadow-xl transition-all duration-300">
<div className="absolute inset-0 bg-[#0A1A2F]/20 group-hover:bg-transparent transition-colors z-10" />
<img
src={url}
alt={`Imagem ${index + 1}`}
className="w-full h-48 object-cover transform group-hover:scale-110 transition-transform duration-700 ease-out"
/>
</a>
)
))}
</div>
)}
{/* Seção de Extras */}
{basePrice !== null && availableExtras.length > 0 && (
<div className="mb-8 animate-fade-in">
<h3 className="text-[#1B3B5F] font-bold text-sm uppercase tracking-wider mb-2">Adicione algo especial à sua experiência</h3>
<div className="grid grid-cols-1 gap-4 mt-4">
{availableExtras.map(extra => {
const isSelected = formData.selectedExtras.includes(extra.id);
return (
<div
key={extra.id}
onClick={() => 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 && <img src={extra.image} className="rounded-xl w-full h-40 object-cover mb-3" alt={extra.title} />}
<div className="flex justify-between items-start">
<h3 className="text-white font-semibold text-lg">{extra.title}</h3>
{extra.tag && (
<span className="px-3 py-1 text-xs rounded-full bg-[#1E90FF] text-white">
{extra.tag}
</span>
)}
</div>
<p className="text-gray-300 text-sm mt-1">{extra.description}</p>
<div className="mt-3 text-[#1E90FF] font-bold text-xl">
{new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(extra.price)}
</div>
</div>
);
})}
</div>
</div>
)}
<div className="my-8">
{isPriceLoading && (
<div className="text-center p-6 bg-[#F8FAFC] rounded-2xl border border-[#1B3B5F]/10">
<div className="w-6 h-6 border-2 border-[#1E90FF] border-t-transparent rounded-full animate-spin mx-auto mb-2"></div>
<p className="text-sm text-[#9CA3AF]">Calculando valor...</p>
</div>
)}
{!isPriceLoading && calculatedPrice !== null && (
<div className="relative overflow-hidden p-6 bg-[#F8FAFC] border-[1.5px] border-[#1E90FF]/20 rounded-2xl animate-fade-in shadow-lg shadow-[#1E90FF]/5">
<div className="absolute top-0 right-0 bg-[#1E90FF] text-white text-[10px] font-bold px-3 py-1 rounded-bl-lg">PREÇO ESTIMADO</div>
<div className="space-y-4">
<div className="flex justify-between items-center text-sm text-[#1B3B5F]">
<span className="font-medium">Valor Total da Reserva</span>
<span className="font-bold text-lg">{new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(calculatedPrice)}</span>
</div>
{formData.selectedExtras.length > 0 && (
<div className="flex justify-between items-center text-xs text-[#9CA3AF]">
<span>(Suíte + Extras)</span>
</div>
)}
<div className="flex justify-between items-center text-sm text-[#9CA3AF]">
<span>Pagar no check-in</span>
<span className="font-medium">{new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(calculatedPrice / 2)}</span>
</div>
<div className="pt-4 border-t border-[#1B3B5F]/10 flex justify-between items-end">
<div>
<p className="text-xs font-bold text-[#1E90FF] uppercase tracking-wider mb-1">Entrada via Pix (50%)</p>
<p className="text-[#9CA3AF] text-xs">Necessário para confirmar</p>
</div>
<span className="text-3xl font-extrabold text-[#1B3B5F] tracking-tight">
{new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(calculatedPrice / 2)}
</span>
</div>
</div>
</div>
)}
</div>
<div className="bg-[#F8FAFC] p-6 rounded-2xl border border-[#1B3B5F]/10 mb-8 shadow-sm">
<h3 className="text-[#1B3B5F] font-bold text-sm uppercase tracking-wider mb-4 border-b border-[#1B3B5F]/10 pb-2 flex items-center gap-2">
<User className="w-4 h-4" />
Seus Dados
</h3>
<div className="space-y-1">
<FormField id="nome" name="nome" label="Nome Completo" value={formData.nome} onChange={handleChange} required placeholder="Como no documento" autoComplete="name" error={!!formErrors.nome} icon={<User className="w-4 h-4" />} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField id="telefone" name="telefone" label="Telefone / WhatsApp" type="tel" value={formData.telefone} onChange={handleChange} required placeholder="(99) 99999-9999" autoComplete="tel" error={!!formErrors.telefone} icon={<Phone className="w-4 h-4" />} />
<FormField id="cpf" name="cpf" label="CPF" type="text" value={formData.cpf} onChange={handleChange} required placeholder="Apenas números" autoComplete="off" error={!!formErrors.cpf} icon={<FileText className="w-4 h-4" />} />
</div>
<FormField id="email" name="email" label="E-mail" type="email" value={formData.email} onChange={handleChange} required placeholder="seu@email.com" autoComplete="email" error={!!formErrors.email} icon={<Mail className="w-4 h-4" />} />
<FormField id="observacao" name="observacao" label="Observação (Opcional)" fieldType="textarea" value={formData.observacao} onChange={handleChange} placeholder="Alguma preferência especial?" rows={2} />
</div>
</div>
{submissionStatus?.type === 'error' && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="p-4 mb-6 rounded-xl text-sm flex items-center bg-red-50 text-red-700 border border-red-100" role="alert"
>
<AlertTriangle className="h-5 w-5 mr-3 flex-shrink-0" />
{submissionStatus.message}
</motion.div>
)}
<Button type="submit" isLoading={isLoading} disabled={isLoading || isDataLoading} size="lg" className="w-full text-lg shadow-xl shadow-[#1E90FF]/30 hover:shadow-[#1E90FF]/50 transition-all duration-300">
{isLoading ? 'Processando...' : 'Confirmar e Pagar Reserva'}
</Button>
</form>
)}
</>
);
}
};
return (
<div className="min-h-screen py-10 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center">
<div className="w-full max-w-3xl bg-white rounded-[2rem] shadow-2xl overflow-hidden border border-white/10 relative">
{/* Decorative Top Accent */}
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-[#1B3B5F] to-[#1E90FF]"></div>
<div className="p-8 sm:p-12">
<div className="flex justify-between items-start mb-10 border-b border-[#1B3B5F]/10 pb-6">
<div className="space-y-1">
<h1 className="text-2xl sm:text-3xl font-extrabold text-[#1B3B5F] tracking-tight">
{view === 'payment' ? 'Pagamento Seguro' :
view === 'success' ? 'Reserva Confirmada' :
view === 'expired' ? 'Tempo Esgotado' :
appConfig.title}
</h1>
{view === 'form' && <p className="text-[#9CA3AF] text-sm font-medium">{appConfig.subtitle}</p>}
</div>
<Button onClick={() => setCurrentView('admin')} variant="outline" size="sm" className="hidden sm:flex rounded-full px-5 text-xs font-bold uppercase tracking-wider hover:bg-[#F8FAFC] flex items-center gap-2">
<Settings className="w-4 h-4" />
Admin
</Button>
</div>
<AnimatePresence mode="wait">
<motion.div
key={view}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
>
{renderContent()}
</motion.div>
</AnimatePresence>
</div>
</div>
<footer className="text-center text-xs font-medium text-[#1E90FF]/60 mt-8">
&copy; {new Date().getFullYear()} {appConfig.title} &bull; Experiência Exclusiva
</footer>
</div>
);
};
export default App;

View File

@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# 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`

View File

@ -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<AdminPageProps> = ({ onNavigateToReservation, currentConfig, onSaveConfig }) => {
const [isAuthenticated, setIsAuthenticated] = React.useState<boolean>(false);
const [email, setEmail] = React.useState<string>('');
const [password, setPassword] = React.useState<string>('');
const [error, setError] = React.useState<string>('');
const [activeTab, setActiveTab] = React.useState<AdminTab>('brands');
const [isLoading, setIsLoading] = React.useState<boolean>(true); // Start as true to check session
const [isSaving, setIsSaving] = React.useState<boolean>(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<Brand[]>([]);
const [editingBrand, setEditingBrand] = React.useState<Brand | null>(null);
const [currentBrand, setCurrentBrand] = React.useState<Partial<Omit<Brand, 'id'>>>({ name: '', suite_categories: [], stay_durations: [] });
const [brandFormVisible, setBrandFormVisible] = React.useState(false);
// Unit state
const [units, setUnits] = React.useState<HotelUnit[]>([]);
const [editingUnit, setEditingUnit] = React.useState<HotelUnit | null>(null);
const [currentUnit, setCurrentUnit] = React.useState<Partial<Omit<HotelUnit, 'id'>> & { 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<Suite[]>([]);
const [editingSuite, setEditingSuite] = React.useState<Suite | null>(null);
const [currentSuite, setCurrentSuite] = React.useState<Partial<Omit<Suite, 'id'>>>({ api_id: undefined, name: '', category: '', unitIds: [] });
const [suiteFormVisible, setSuiteFormVisible] = React.useState(false);
// Pricing state
const [selectedBrandForPricing, setSelectedBrandForPricing] = React.useState<string>('');
const [pricingData, setPricingData] = React.useState<PricingData | null>(null);
const [isPricingLoading, setIsPricingLoading] = React.useState<boolean>(false);
// Extras state
const [extras, setExtras] = React.useState<ExtraItem[]>([]);
const [editingExtra, setEditingExtra] = React.useState<ExtraItem | null>(null);
const [extraFormVisible, setExtraFormVisible] = React.useState(false);
const [currentExtra, setCurrentExtra] = React.useState<Partial<ExtraItem>>({
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 <p className="mt-4 text-slate-500">Por favor, selecione uma marca para gerenciar os preços.</p>;
}
if (isPricingLoading) {
return <p className="mt-4 text-slate-500">Carregando tabela de preços...</p>
}
if (!pricingData) {
return <p className="mt-4 text-red-500">Não foi possível carregar os dados de preços. Tente selecionar a marca novamente.</p>;
}
if (!selectedBrand.suite_categories || selectedBrand.suite_categories.length === 0 || !selectedBrand.stay_durations || selectedBrand.stay_durations.length === 0) {
return <p className="mt-4 text-slate-500">Para definir os preços, primeiro adicione 'Categorias de Suíte' e 'Tipos de Permanência' na aba de 'Marcas'.</p>;
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-12 mt-6">
{Object.entries(pricingData).map(([dayRange, categories]) => (
<div key={dayRange} className="space-y-6">
<h3 className="text-lg font-bold text-sky-700 border-b pb-2">{dayRange}</h3>
{Object.entries(categories).map(([category, durations]) => (
<div key={category} className="p-4 bg-slate-50 rounded-lg">
<h4 className="font-semibold text-slate-700 mb-4">{category}</h4>
<div className="space-y-3">
{Object.entries(durations).map(([duration, price]) => (
<div key={duration} className="grid grid-cols-3 gap-4 items-center">
<label htmlFor={`${dayRange}-${category}-${duration}`} className="text-sm text-slate-600 col-span-2">{duration}</label>
<FormField id={`${dayRange}-${category}-${duration}`} name="price" type="number" value={String(price)} onChange={(e) => handlePriceChange(dayRange, category, duration, e.target.value)} label="" inputPrefix="R$" />
</div>
))}
</div>
</div>
))}
</div>
))}
</div>
);
};
// --- 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 (
<div className="min-h-screen bg-slate-800 flex items-center justify-center">
<p className="text-white">Verificando sessão...</p>
</div>
);
}
if (!isAuthenticated) {
return (
<div className="min-h-screen bg-slate-800 py-8 px-4 flex flex-col items-center justify-center">
<div className="bg-white p-8 md:p-10 rounded-xl shadow-2xl w-full max-w-md">
<h1 className="text-2xl font-bold text-center text-sky-700 mb-6">Login Administrativo</h1>
<form onSubmit={handleLogin}>
<FormField id="email" name="email" type="email" label="E-mail" value={email} onChange={(e) => setEmail(e.target.value)} required autoComplete="email" />
<FormField id="password" name="password" type="password" label="Senha" value={password} onChange={(e) => setPassword(e.target.value)} required autoComplete="current-password" />
{error && <p className="text-red-500 text-sm mt-2 mb-4">{error}</p>}
<Button type="submit" className="w-full mt-4" isLoading={isSaving}>Entrar</Button>
</form>
<Button onClick={onNavigateToReservation} variant="secondary" className="w-full mt-4">Voltar para Reservas</Button>
</div>
</div>
);
}
const brandOptions = brands.map(b => ({ value: String(b.id), label: b.name }));
// --- RENDER ---
return (
<div className="min-h-screen bg-slate-100 py-8 px-4">
<div className="container mx-auto max-w-6xl">
<div className="flex flex-col sm:flex-row justify-between sm:items-center mb-10 gap-6 border-b border-[#1B3B5F]/10 pb-8">
<div className="flex items-center gap-4">
<div className="p-3 bg-[#1E90FF]/10 rounded-2xl">
<LayoutDashboard className="w-8 h-8 text-[#1E90FF]" />
</div>
<div>
<h1 className="text-3xl font-extrabold text-[#1B3B5F] tracking-tight">Painel Administrativo</h1>
<p className="text-[#9CA3AF] text-sm font-medium">Gerencie marcas, unidades e reservas</p>
</div>
</div>
<div className="flex items-center gap-3">
<Button onClick={handleLogout} variant="secondary" size="sm" className="flex items-center gap-2 text-red-500 hover:bg-red-50 rounded-xl px-4">
<LogOut className="w-4 h-4" />
Sair
</Button>
<Button onClick={onNavigateToReservation} variant="secondary" size="sm" className="flex items-center gap-2 rounded-xl px-4">
<ChevronLeft className="w-4 h-4" />
Voltar ao Site
</Button>
</div>
</div>
<div className="flex flex-wrap gap-2 mb-8">
<TabButton tabId="brands" activeTab={activeTab} onClick={setActiveTab} icon={<Building2 className="w-4 h-4" />}>Marcas</TabButton>
<TabButton tabId="units" activeTab={activeTab} onClick={setActiveTab} icon={<Hotel className="w-4 h-4" />}>Unidades</TabButton>
<TabButton tabId="suites" activeTab={activeTab} onClick={setActiveTab} icon={<BedDouble className="w-4 h-4" />}>Suítes</TabButton>
<TabButton tabId="prices" activeTab={activeTab} onClick={setActiveTab} icon={<DollarSign className="w-4 h-4" />}>Preços</TabButton>
<TabButton tabId="extras" activeTab={activeTab} onClick={setActiveTab} icon={<Sparkles className="w-4 h-4" />}>Extras & Pacotes</TabButton>
<TabButton tabId="settings" activeTab={activeTab} onClick={setActiveTab} icon={<Settings className="w-4 h-4" />}>Configurações</TabButton>
</div>
{error && <div className="p-3 mb-4 rounded-md text-sm bg-red-100 text-red-800">{error}</div>}
{saveStatus && <div className={`p-3 mb-4 rounded-md text-sm ${saveStatus.type === 'success' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>{saveStatus.message}</div>}
{isLoading ? (
<div className="text-center py-20 flex flex-col items-center justify-center space-y-4">
<Loader2 className="w-8 h-8 text-[#1E90FF] animate-spin" />
<p className="text-[#9CA3AF] font-medium animate-pulse">Carregando dados do sistema...</p>
</div>
) : (
<AnimatePresence mode="wait">
<motion.div
key={activeTab}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
{/* Brands Tab */}
<div className={activeTab === 'brands' ? 'block' : 'hidden'}>
<div className="bg-white p-6 rounded-lg shadow-lg">
<h2 className="text-xl font-semibold text-slate-800 mb-4">Gerenciar Marcas</h2>
{!brandFormVisible && <Button onClick={() => { setCurrentBrand({ name: '', suite_categories: [], stay_durations: []}); setEditingBrand(null); setBrandFormVisible(true); }} className="mb-6">Adicionar Nova Marca</Button>}
{brandFormVisible && (
<form onSubmit={handleBrandFormSubmit} className="bg-slate-50 p-4 rounded-lg mb-6 border">
<h3 className="text-lg font-semibold mb-4">{editingBrand ? 'Editar Marca' : 'Nova Marca'}</h3>
<FormField label="Nome da Marca" id="brandName" name="name" value={currentBrand.name || ''} onChange={(e) => setCurrentBrand(p => ({...p, name: e.target.value}))} required />
<FormField label="Categorias de Suíte (separadas por vírgula)" id="brandCategories" name="suite_categories" value={currentBrand.suite_categories?.join(', ') || ''} onChange={(e) => setCurrentBrand(p => ({...p, suite_categories: e.target.value.split(',').map(s => s.trim())}))} />
<FormField label="Tipos de Permanência (separadas por vírgula)" id="brandDurations" name="stay_durations" value={currentBrand.stay_durations?.join(', ') || ''} onChange={(e) => setCurrentBrand(p => ({...p, stay_durations: e.target.value.split(',').map(s => s.trim())}))} />
<div className="flex gap-4 mt-6">
<Button type="submit" isLoading={isSaving}>{editingBrand ? 'Salvar' : 'Adicionar'}</Button>
<Button type="button" variant="secondary" onClick={() => setBrandFormVisible(false)}>Cancelar</Button>
</div>
</form>
)}
<DataTable
headers={['Nome', 'Categorias', 'Permanências', 'Ações']}
data={brands.map(b => ({
id: b.id,
cells: [
b.name,
b.suite_categories.join(', '),
b.stay_durations.join(', '),
<ActionButtons onEdit={() => handleEditBrand(b)} onDelete={() => handleDeleteBrand(b.id)} />
]
}))}
/>
</div>
</div>
{/* Units Tab */}
<div className={activeTab === 'units' ? 'block' : 'hidden'}>
<div className="bg-white p-6 rounded-lg shadow-lg">
<h2 className="text-xl font-semibold text-slate-800 mb-4">Gerenciar Unidades</h2>
{!unitFormVisible && <Button onClick={() => { setCurrentUnit({name: '', brandId: undefined, visible_suite_categories: [], suite_category_images: []}); setEditingUnit(null); setUnitFormVisible(true); }} className="mb-6" disabled={brands.length === 0}>Adicionar Nova Unidade</Button>}
{brands.length === 0 && <p className="text-sm text-slate-500">Crie uma marca antes de adicionar unidades.</p>}
{unitFormVisible && (
<form onSubmit={handleUnitFormSubmit} className="bg-slate-50 p-4 rounded-lg mb-6 border">
<h3 className="text-lg font-semibold mb-4">{editingUnit ? 'Editar Unidade' : 'Nova Unidade'}</h3>
<SelectField label="Marca" id="unitBrand" name="brandId" value={String(currentUnit.brandId || '')} onChange={(e) => setCurrentUnit(p => ({...p, brandId: parseInt(e.target.value, 10), visible_suite_categories: [] }))} options={brandOptions} required />
<FormField label="Nome da Unidade" id="unitName" name="name" value={currentUnit.name || ''} onChange={(e) => setCurrentUnit(p => ({...p, name: e.target.value}))} required />
{currentUnit.brandId && (
<div className="mt-4 pt-4 border-t border-slate-200">
<h4 className="block text-sm font-medium text-slate-700 mb-2">Categorias e Imagens</h4>
<p className="mb-4 text-xs text-slate-500">Selecione as categorias visíveis e cole as URLs públicas das imagens (do Backblaze B2).</p>
<div className="space-y-4">
{(brands.find(b => b.id === currentUnit.brandId)?.suite_categories || []).map(category => {
const imageInfo = currentUnit.suite_category_images?.find(ci => ci.category === category);
return (
<div key={category} className="p-3 bg-white rounded-md border">
<div className="flex items-center mb-3">
<input type="checkbox" id={`cat-${category}`} checked={currentUnit.visible_suite_categories?.includes(category) ?? false} onChange={e => handleCategoryVisibilityChange(category, e.target.checked)} className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500" />
<label htmlFor={`cat-${category}`} className="ml-3 block text-sm font-semibold text-gray-800">{category}</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pl-7">
<FormField label="URL da Imagem 1" id={`img1-${category}`} name={`img1-${category}`} value={imageInfo?.imageUrls?.[0] || ''} onChange={(e) => handleCategoryImageURLChange(category, e.target.value, 0)} placeholder="https://.../imagem1.jpg" />
<FormField label="URL da Imagem 2" id={`img2-${category}`} name={`img2-${category}`} value={imageInfo?.imageUrls?.[1] || ''} onChange={(e) => handleCategoryImageURLChange(category, e.target.value, 1)} placeholder="https://.../imagem2.jpg" />
</div>
</div>
)})}
</div>
</div>
)}
<div className="flex gap-4 mt-6">
<Button type="submit" isLoading={isSaving}>{editingUnit ? 'Salvar' : 'Adicionar'}</Button>
<Button type="button" variant="secondary" onClick={() => setUnitFormVisible(false)}>Cancelar</Button>
</div>
</form>
)}
<DataTable
headers={['Nome da Unidade', 'Marca', 'Categorias Visíveis', 'Ações']}
data={units.map(u => ({
id: u.id,
cells: [
u.name,
brands.find(b=>b.id === u.brandId)?.name || 'N/A',
(u.visible_suite_categories || []).join(', '),
<ActionButtons onEdit={() => handleEditUnit(u)} onDelete={() => handleDeleteUnit(u.id)} />
]
}))}
/>
</div>
</div>
{/* Suites Tab */}
<div className={activeTab === 'suites' ? 'block' : 'hidden'}>
<div className="bg-white p-6 rounded-lg shadow-lg">
<h2 className="text-xl font-semibold text-slate-800 mb-4">Gerenciar Suítes</h2>
{!suiteFormVisible && <Button onClick={() => { setCurrentSuite({api_id: undefined, name: '', category: '', unitIds: []}); setEditingSuite(null); setSuiteFormVisible(true); }} className="mb-6">Adicionar Nova Suíte</Button>}
{suiteFormVisible && (
<form onSubmit={handleSuiteFormSubmit} className="bg-slate-50 p-4 rounded-lg mb-6 border">
<h3 className="text-lg font-semibold mb-4">{editingSuite ? 'Editar Suíte' : 'Nova Suíte'}</h3>
<FormField label="ID da API" type="number" id="suiteApiId" name="api_id" value={String(currentSuite.api_id || '')} onChange={(e) => setCurrentSuite(p => ({...p, api_id: parseInt(e.target.value, 10)}))} required />
<FormField label="Nome" id="suiteName" name="name" value={currentSuite.name || ''} onChange={(e) => setCurrentSuite(p => ({...p, name: e.target.value}))} required />
<FormField label="Categoria" id="suiteCategory" name="category" value={currentSuite.category || ''} onChange={(e) => setCurrentSuite(p => ({...p, category: e.target.value}))} required instruction="A categoria deve corresponder a uma das categorias cadastradas na marca." />
<div className="mt-4 pt-4 border-t border-slate-200">
<label className="block text-sm font-medium text-slate-700 mb-2">Disponível nas Unidades</label>
<div className="space-y-2">
{units.length > 0 ? units.map(unit => (
<div key={unit.id} className="flex items-center">
<input
type="checkbox"
id={`suite-unit-${unit.id}`}
checked={currentSuite.unitIds?.includes(unit.id) ?? false}
onChange={e => handleSuiteUnitAssignmentChange(unit.id, e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500" />
<label htmlFor={`suite-unit-${unit.id}`} className="ml-3 block text-sm text-gray-700">{unit.name} ({brands.find(b => b.id === unit.brandId)?.name})</label>
</div>
)) : <p className="text-xs text-slate-500">Nenhuma unidade cadastrada. Crie unidades na aba 'Unidades'.</p>}
</div>
<p className="mt-1.5 text-xs text-slate-500">Marque as unidades onde esta suíte estará disponível.</p>
</div>
<div className="flex gap-4 mt-6">
<Button type="submit" isLoading={isSaving}>{editingSuite ? 'Salvar' : 'Adicionar'}</Button>
<Button type="button" variant="secondary" onClick={() => setSuiteFormVisible(false)}>Cancelar</Button>
</div>
</form>
)}
<DataTable
headers={['ID da API', 'Nome', 'Categoria', 'Unidades', 'Ações']}
data={suites.map(s => ({
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',
<ActionButtons onEdit={() => handleEditSuite(s)} onDelete={() => handleDeleteSuite(s.id)} />
]
}))}
/>
</div>
</div>
{/* Pricing Tab */}
<div className={activeTab === 'prices' ? 'block' : 'hidden'}>
<div className="bg-white p-6 rounded-lg shadow-lg">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-slate-800">Tabela de Preços</h2>
<Button onClick={handleSavePrices} isLoading={isSaving} disabled={!pricingData}>Salvar Preços</Button>
</div>
<SelectField label="Selecione a Marca para editar os preços" id="pricingBrand" name="brandId" value={selectedBrandForPricing} onChange={(e) => setSelectedBrandForPricing(e.target.value)} options={brandOptions} />
{renderPricingContent()}
</div>
</div>
{/* Extras Tab */}
<div className={activeTab === 'extras' ? 'block' : 'hidden'}>
<div className="bg-white p-6 rounded-lg shadow-lg">
<h2 className="text-xl font-semibold text-slate-800 mb-4">Catálogo de Extras & Pacotes</h2>
{!extraFormVisible && <Button onClick={() => { setCurrentExtra({title: '', price: 0, description: '', image: '', category: '', tag: '', active: true, order: 0}); setEditingExtra(null); setExtraFormVisible(true); }} className="mb-6">Adicionar Novo Extra</Button>}
{extraFormVisible && (
<form onSubmit={handleExtraFormSubmit} className="bg-slate-50 p-4 rounded-lg mb-6 border space-y-4">
<h3 className="text-lg font-semibold mb-4">{editingExtra ? 'Editar Extra' : 'Novo Extra'}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField label="Título" id="extraTitle" name="title" value={currentExtra.title || ''} onChange={(e) => setCurrentExtra(p => ({...p, title: e.target.value}))} required />
<FormField label="Preço (R$)" id="extraPrice" name="price" type="number" value={String(currentExtra.price)} onChange={(e) => setCurrentExtra(p => ({...p, price: parseFloat(e.target.value)}))} required />
</div>
<FormField label="Descrição" id="extraDescription" name="description" value={currentExtra.description || ''} onChange={(e) => setCurrentExtra(p => ({...p, description: e.target.value}))} fieldType="textarea" rows={2} />
<FormField label="URL da Imagem" id="extraImage" name="image" value={currentExtra.image || ''} onChange={(e) => setCurrentExtra(p => ({...p, image: e.target.value}))} placeholder="https://..." />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField label="Categoria (Ex: Bebidas, Romântico)" id="extraCategory" name="category" value={currentExtra.category || ''} onChange={(e) => setCurrentExtra(p => ({...p, category: e.target.value}))} />
<FormField label="Tag (Ex: Mais pedido)" id="extraTag" name="tag" value={currentExtra.tag || ''} onChange={(e) => setCurrentExtra(p => ({...p, tag: e.target.value}))} />
<FormField label="Ordem de Exibição" id="extraOrder" name="order" type="number" value={String(currentExtra.order || 0)} onChange={(e) => setCurrentExtra(p => ({...p, order: parseInt(e.target.value)}))} />
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="extraActive"
checked={currentExtra.active ?? true}
onChange={(e) => setCurrentExtra(p => ({...p, active: e.target.checked}))}
className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500"
/>
<label htmlFor="extraActive" className="text-sm font-medium text-gray-700">Item Ativo no Formulário</label>
</div>
<div className="flex gap-4 mt-6">
<Button type="submit">{editingExtra ? 'Salvar' : 'Adicionar'}</Button>
<Button type="button" variant="secondary" onClick={() => setExtraFormVisible(false)}>Cancelar</Button>
</div>
</form>
)}
<DataTable
headers={['Imagem', 'Título', 'Categoria', 'Preço', 'Ordem', 'Status', 'Ações']}
data={extras.map(e => ({
id: e.id,
cells: [
e.image ? <img src={e.image} alt={e.title} className="w-12 h-12 object-cover rounded-md" /> : 'Sem img',
e.title,
e.category || '-',
`R$ ${e.price.toFixed(2)}`,
e.order || 0,
<span className={`px-2 py-1 rounded text-xs ${e.active ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>{e.active ? 'Ativo' : 'Inativo'}</span>,
<div className="space-x-2">
<Button onClick={() => handleToggleExtraStatus(e.id)} variant="secondary" className="!py-1 !px-2 text-xs">{e.active ? 'Desativar' : 'Ativar'}</Button>
<Button onClick={() => handleEditExtra(e)} variant="secondary" className="text-sky-600 hover:text-sky-900 !py-1 !px-2 text-xs">Editar</Button>
<Button onClick={() => handleDeleteExtra(e.id)} variant="secondary" className="text-red-600 hover:text-red-900 !py-1 !px-2 text-xs">Excluir</Button>
</div>
]
}))}
/>
</div>
</div>
{/* Settings Tab */}
<div className={activeTab === 'settings' ? 'block' : 'hidden'}>
<div className="bg-white p-6 rounded-lg shadow-lg max-w-2xl mx-auto">
<h2 className="text-xl font-semibold text-slate-800 mb-4">Configurações Gerais</h2>
<p className="text-sm text-slate-500 mb-6 bg-yellow-50 p-3 rounded-md border border-yellow-200">
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.
</p>
<form onSubmit={handleSettingsSubmit} className="space-y-4">
<FormField
label="Título Principal"
id="appTitle"
name="title"
value={localSettings.title}
onChange={(e) => setLocalSettings(prev => ({ ...prev, title: e.target.value }))}
placeholder="Ex: Reserva Premium"
required
/>
<FormField
label="Subtítulo / Nome do Hotel"
id="appSubtitle"
name="subtitle"
value={localSettings.subtitle}
onChange={(e) => setLocalSettings(prev => ({ ...prev, subtitle: e.target.value }))}
placeholder="Ex: Hotel 1001 Noites Prime"
required
/>
<Button type="submit">Salvar Alterações</Button>
</form>
</div>
</div>
</motion.div>
</AnimatePresence>
)}
</div>
</div>
);
};
const TabButton: React.FC<{
tabId: AdminTab;
activeTab: AdminTab;
children: React.ReactNode;
onClick: (tabId: AdminTab) => void;
icon?: React.ReactNode;
}> = ({ tabId, activeTab, children, onClick, icon }) => (
<button
onClick={() => onClick(tabId)}
className={cn(
'px-4 py-2.5 text-sm font-semibold rounded-xl transition-all duration-200 flex items-center gap-2',
activeTab === tabId
? 'bg-[#1E90FF] text-white shadow-lg shadow-[#1E90FF]/30 translate-y-[-1px]'
: 'bg-white text-[#1B3B5F] hover:bg-[#F8FAFC] border border-[#1B3B5F]/10'
)}
>
{icon}
{children}
</button>
);
interface DataRow {
id: string | number;
cells: (string | number | React.ReactNode)[];
}
const DataTable: React.FC<{ headers: string[]; data: DataRow[]}> = ({ headers, data }) => (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-slate-200">
<thead className="bg-slate-50">
<tr>{headers.map(h => <th key={h} className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">{h}</th>)}</tr>
</thead>
<tbody className="bg-white divide-y divide-slate-200">
{data.length === 0 ? <tr><td colSpan={headers.length} className="px-6 py-4 text-center text-slate-500">Nenhum dado encontrado.</td></tr> :
data.map(row => <tr key={row.id}>{row.cells.map((cell, j) => <td key={j} className="px-6 py-4 whitespace-nowrap text-sm text-slate-600">{cell}</td>)}</tr>)}
</tbody>
</table>
</div>
);
const ActionButtons: React.FC<{ onEdit: () => void; onDelete: () => void }> = ({ onEdit, onDelete }) => (
<div className="flex items-center gap-2">
<Button onClick={onEdit} variant="secondary" size="sm" className="h-8 w-8 p-0 text-[#1E90FF] hover:bg-[#1E90FF]/10">
<Edit2 className="w-4 h-4" />
</Button>
<Button onClick={onDelete} variant="secondary" size="sm" className="h-8 w-8 p-0 text-red-500 hover:bg-red-50">
<Trash2 className="w-4 h-4" />
</Button>
</div>
);
export default AdminPage;

View File

@ -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<HTMLInputElement | HTMLTextAreaElement>) => 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<FormFieldProps> = ({
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 (
<textarea
id={id}
name={name}
value={value}
onChange={onChange}
required={required}
placeholder={placeholder}
rows={rows}
className={finalInputClasses}
autoComplete={autoComplete}
aria-invalid={error}
/>
);
}
return (
<input
id={id}
name={name}
type={type}
value={value}
onChange={onChange}
required={required}
placeholder={placeholder}
className={finalInputClasses}
autoComplete={autoComplete}
step={type === 'number' ? 'any' : undefined}
style={dateTimeStyles}
aria-invalid={error}
/>
);
};
return (
<div className="mb-6 group">
<label htmlFor={id} className="block text-xs font-semibold text-[#1B3B5F] uppercase tracking-wider mb-2 ml-1">
{label} {required && <span className="text-[#1E90FF]">*</span>}
</label>
<div className="relative flex shadow-sm rounded-xl">
{inputPrefix && (
<span className={cn(
"inline-flex items-center rounded-l-xl border-[1.5px] border-r-0 bg-[#F8FAFC] px-4 text-sm font-medium text-[#9CA3AF] transition-colors duration-200",
error ? "border-red-500" : "border-[#1B3B5F]/20"
)}>
{inputPrefix}
</span>
)}
{icon && (
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-[#9CA3AF] z-10">
{icon}
</div>
)}
{renderInput()}
</div>
{instruction && <p className="mt-2 text-xs text-[#9CA3AF] ml-1">{instruction}</p>}
</div>
);
};
export default FormField;

View File

@ -0,0 +1,95 @@
import * as React from 'react';
import { cn } from '../lib/utils.ts';
interface Option {
value: string;
label: string;
}
interface SelectFieldProps {
id: string;
name: string;
label: string;
value: string;
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
options: Option[];
required?: boolean;
placeholder?: string; // For the default/disabled option
instruction?: string;
disabled?: boolean;
error?: boolean; // New prop for validation state
icon?: React.ReactNode; // New prop for icon
}
const SelectField: React.FC<SelectFieldProps> = ({
id,
name,
label,
value,
onChange,
options,
required = false,
placeholder = 'Selecione uma opção',
instruction,
disabled = false,
error = false,
icon,
}) => {
const selectClassName = cn(
"block w-full appearance-none rounded-xl border-[1.5px] bg-[#F8FAFC] px-4 py-3.5 text-sm font-medium text-[#1B3B5F]",
"transition-all duration-300 ease-out",
"focus:outline-none focus:border-[#1E90FF] focus:bg-white focus:ring-4 focus:ring-[#1E90FF]/10",
"hover:border-[#1B3B5F]/50",
"disabled:bg-slate-100 disabled:text-slate-400 disabled:border-slate-200 disabled:cursor-not-allowed",
icon ? 'pl-11' : '',
error
? "border-red-500 text-red-900 focus:border-red-500 focus:ring-red-500/10"
: "border-[#1B3B5F]/20"
);
return (
<div className="mb-6 group">
<label htmlFor={id} className="block text-xs font-semibold text-[#1B3B5F] uppercase tracking-wider mb-2 ml-1">
{label} {required && <span className="text-[#1E90FF]">*</span>}
</label>
<div className="relative shadow-sm rounded-xl">
{icon && (
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-[#9CA3AF] z-10">
{icon}
</div>
)}
<select
id={id}
name={name}
value={value}
onChange={onChange}
required={required}
className={selectClassName}
disabled={disabled}
aria-describedby={instruction ? `${id}-instruction` : undefined}
aria-invalid={error}
>
{placeholder && <option value="" disabled={required || value !== ""}>{placeholder}</option>}
{options.map(option => (
<option key={option.value} value={option.value} className="text-[#1B3B5F] py-2">
{option.label}
</option>
))}
</select>
<div className={cn(
"pointer-events-none absolute inset-y-0 right-0 flex items-center px-4 transition-colors duration-200",
error ? "text-red-500" : "text-[#1B3B5F]/50 group-hover:text-[#1E90FF]"
)}>
<svg className="h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" />
</svg>
</div>
</div>
{instruction && <p id={`${id}-instruction`} className="mt-2 text-xs text-[#9CA3AF] ml-1">{instruction}</p>}
</div>
);
};
export default SelectField;

View File

@ -0,0 +1,94 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils.ts";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-xl text-sm font-bold ring-offset-white transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1E90FF] focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-60 tracking-wide",
{
variants: {
variant: {
default: "bg-gradient-to-r from-[#1B3B5F] to-[#1E90FF] text-white shadow-lg shadow-[#1E90FF]/25 hover:shadow-xl hover:shadow-[#1E90FF]/40 hover:-translate-y-0.5 border border-transparent",
destructive:
"bg-red-500 text-white hover:bg-red-600 shadow-md",
outline:
"border-[1.5px] border-[#1B3B5F]/20 bg-transparent hover:bg-[#F8FAFC] text-[#1B3B5F] hover:border-[#1B3B5F]/50",
secondary: "bg-[#F8FAFC] text-[#1B3B5F] hover:bg-slate-100 border border-[#9CA3AF]/20",
ghost: "hover:bg-slate-100 hover:text-[#1B3B5F]",
link: "text-[#1E90FF] underline-offset-4 hover:underline",
},
size: {
default: "h-12 px-6 py-3",
sm: "h-10 rounded-lg px-4 text-xs",
lg: "h-14 rounded-xl px-10 text-base",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
isLoading?: boolean;
};
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
variant,
size,
asChild = false,
isLoading = false,
children,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button";
const calculatedDisabled = isLoading || props.disabled;
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
disabled={calculatedDisabled}
{...props}
>
{isLoading && (
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
)}
{children}
</Comp>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@ -0,0 +1,12 @@
import * as React from "react";
/**
* Stub component to completely disable Confetti functionality for debugging purposes.
* This component renders a plain button and uses no React hooks or complex dependencies
* to help isolate potential errors.
*/
export const Confetti: React.FC = () => null;
export const ConfettiButton: React.FC<React.ButtonHTMLAttributes<HTMLButtonElement>> = (props) => (
<button {...props}>{props.children}</button>
);

View File

@ -0,0 +1,41 @@
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
}
:root {
--bg: #0A1A2F;
--ink: #F8FAFC;
--accent: #1E90FF;
}
body {
background-color: var(--bg);
color: var(--ink);
font-family: var(--font-sans);
}
.animate-fade-in {
animation: fadeIn 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* Custom styles for the reservation form */
.form-card {
background: white;
border-radius: 2rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
.input-field {
@apply w-full bg-[#F8FAFC] border-[1.5px] border-[#1B3B5F]/20 rounded-xl p-3 text-sm text-[#1B3B5F] focus:outline-none focus:border-[#1E90FF] focus:ring-2 focus:ring-[#1E90FF]/10 transition-all;
}
.label-text {
@apply block text-xs font-bold text-[#1B3B5F] uppercase tracking-wider mb-1.5;
}

View File

@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Reserva Hotel 1001 Noites Prime</title>
<style>
/* Minimal body styling for better appearance */
body {
font-family: 'Inter', sans-serif;
}
/* Add a subtle background to the body */
html, body {
height: 100%;
margin: 0;
}
/* Smooth scrolling */
html {
scroll-behavior: smooth;
}
</style>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@19.1.0",
"react/jsx-runtime": "https://esm.sh/react@19.1.0/jsx-runtime",
"react/jsx-dev-runtime": "https://esm.sh/react@19.1.0/jsx-dev-runtime",
"react-dom/client": "https://esm.sh/react-dom@19.1.0/client",
"@radix-ui/react-slot": "https://esm.sh/@radix-ui/react-slot@1.0.2",
"class-variance-authority": "https://esm.sh/class-variance-authority@0.7.0",
"canvas-confetti": "https://esm.sh/canvas-confetti@1.9.3",
"clsx": "https://esm.sh/clsx@2.1.1",
"tailwind-merge": "https://esm.sh/tailwind-merge@2.4.0",
"@supabase/supabase-js": "https://esm.sh/@supabase/supabase-js@2.45.0",
"react-dom/": "https://esm.sh/react-dom@^19.1.0/",
"react/": "https://esm.sh/react@^19.1.0/"
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,15 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom/client';
import App from './App.tsx';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1,7 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@ -0,0 +1,6 @@
{
"name": "Hotel 1001 Noites Prime - Reserva",
"description": "Um formulário de reserva para o Hotel 1001 Noites Prime Águas Claras. Permite aos usuários inserir seus dados, data de check-in e enviar a reserva.",
"requestFramePermissions": [],
"prompt": ""
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,32 @@
{
"name": "hotel-1001-noites-prime---reserva",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "tsc --noEmit"
},
"dependencies": {
"@radix-ui/react-slot": "1.0.2",
"@supabase/supabase-js": "2.45.0",
"canvas-confetti": "1.9.3",
"class-variance-authority": "0.7.0",
"clsx": "2.1.1",
"lucide-react": "^0.575.0",
"motion": "^12.34.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tailwind-merge": "2.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"tailwindcss": "^4.2.1",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

View File

@ -0,0 +1,82 @@
import { ApiPostPayload, N8nApiResponse } from '../types.ts';
const API_ENDPOINT = 'https://webhookn8n.innova1001.com.br/webhook/reservas_grupo1001'; // This endpoint will now handle the reservation
const VERIFICATION_ENDPOINT = 'https://qdpzlxqsjbyxcajinixi.supabase.co/functions/v1/verificar_pagamento';
interface PaymentStatusResponse {
status: 'pago' | 'aguardando_pagamento' | string;
}
export const checkPaymentStatus = async (txid: string): Promise<PaymentStatusResponse> => {
const response = await fetch(VERIFICATION_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ txid }),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Erro ao verificar pagamento: ${response.status} - ${errorText}`);
}
// Handle both object and array-wrapped object responses for robustness
let data = await response.json();
if (Array.isArray(data) && data.length > 0) {
data = data[0];
}
return data;
};
export const submitReservation = async (payload: ApiPostPayload): Promise<N8nApiResponse> => {
const response = await fetch(API_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
let detailedErrorMessage = `Erro HTTP: ${response.status} ${response.statusText}`;
try {
const errorData = await response.json();
if (errorData && errorData.message) {
detailedErrorMessage = errorData.message;
}
} catch (e) {
// Ignore if error response is not JSON
}
throw new Error(detailedErrorMessage);
}
const responseText = await response.text();
if (!responseText) {
throw new Error('Resposta do servidor vazia. A API não retornou os dados do Pix necessários.');
}
try {
let successData = JSON.parse(responseText);
// The webhook might return an array with a single object.
// We check for that and extract the object if necessary.
if (Array.isArray(successData) && successData.length > 0) {
successData = successData[0];
}
// After potentially unwrapping, validate the final object has the required fields.
if (successData && successData.pixCopiaECola && successData.pixUrl && successData.txid) {
return successData as N8nApiResponse;
} else {
console.error("Parsed API response is missing required PIX fields:", successData);
throw new Error('A API retornou uma resposta, mas os dados do Pix estão incompletos ou em formato inesperado.');
}
} catch (e) {
console.error("Failed to parse JSON response:", responseText);
throw new Error(`Resposta do servidor inválida. Não foi possível processar os dados recebidos.`);
}
};

View File

@ -0,0 +1,63 @@
import { supabase } from '../supabaseClient.ts';
import type { Brand, Database, Json } from '../types.ts';
import { pricingService } from './pricingService.ts';
const fromSupabase = (data: any): Brand => ({
id: data.id,
name: data.name,
suite_categories: data.suite_categories || [],
stay_durations: data.stay_durations || [],
});
export const brandService = {
async getAllBrands(): Promise<Brand[]> {
const { data, error } = await supabase.from('brands').select('*').order('name');
if (error) throw new Error(error.message);
return data.map(fromSupabase);
},
async addBrand(newBrand: Omit<Brand, 'id'>): Promise<Brand> {
const payload: Database['public']['Tables']['brands']['Insert'] = {
name: newBrand.name,
suite_categories: newBrand.suite_categories,
stay_durations: newBrand.stay_durations,
};
const { data, error } = await supabase
.from('brands')
.insert([payload])
.select()
.single();
if (error) throw new Error(error.message);
const createdBrand = fromSupabase(data);
// Cria a estrutura de preços para a nova marca
await pricingService.syncPricingForBrand(createdBrand);
return createdBrand;
},
async updateBrand(updatedBrand: Brand): Promise<Brand> {
const payload: Database['public']['Tables']['brands']['Update'] = {
name: updatedBrand.name,
suite_categories: updatedBrand.suite_categories,
stay_durations: updatedBrand.stay_durations,
};
const { data, error } = await supabase
.from('brands')
.update(payload)
.eq('id', updatedBrand.id)
.select()
.single();
if (error) throw new Error(error.message);
const brand = fromSupabase(data);
// Sincroniza a estrutura de preços caso categorias/durações tenham mudado
await pricingService.syncPricingForBrand(brand);
return brand;
},
async deleteBrand(brandId: number): Promise<void> {
// A deleção em cascata no DB removerá unidades e preços associados
const { error } = await supabase.from('brands').delete().eq('id', brandId);
if (error) throw new Error(error.message);
},
};

View File

@ -0,0 +1,63 @@
import { ExtraItem } from '../types.ts';
const STORAGE_KEY = 'extrasCatalog';
export const extraService = {
getExtras(): ExtraItem[] {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
} catch (e) {
console.error("Erro ao carregar extras:", e);
return [];
}
},
saveExtras(extras: ExtraItem[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(extras));
} catch (e) {
console.error("Erro ao salvar extras:", e);
}
},
addExtra(extra: Omit<ExtraItem, 'id'>): ExtraItem {
const extras = this.getExtras();
const newExtra: ExtraItem = {
...extra,
id: crypto.randomUUID(), // Gera um ID único
};
const updatedExtras = [...extras, newExtra];
this.saveExtras(updatedExtras);
return newExtra;
},
updateExtra(updatedExtra: ExtraItem): ExtraItem {
const extras = this.getExtras();
const index = extras.findIndex(e => e.id === updatedExtra.id);
if (index !== -1) {
extras[index] = updatedExtra;
this.saveExtras(extras);
return updatedExtra;
}
throw new Error("Extra não encontrado");
},
deleteExtra(id: string): void {
const extras = this.getExtras();
const updatedExtras = extras.filter(e => e.id !== id);
this.saveExtras(updatedExtras);
},
toggleStatus(id: string): ExtraItem | null {
const extras = this.getExtras();
const index = extras.findIndex(e => e.id === id);
if (index !== -1) {
extras[index].active = !extras[index].active;
this.saveExtras(extras);
return extras[index];
}
return null;
}
};

View File

@ -0,0 +1,67 @@
import { supabase } from '../supabaseClient.ts';
import type { HotelUnit, Database, Json } from '../types.ts';
// Mapeia os dados do Supabase (snake_case) para o tipo HotelUnit (camelCase)
const fromSupabase = (data: any): HotelUnit => ({
id: data.id,
name: data.name,
brandId: data.brand_id,
visible_suite_categories: data.visible_suite_categories || [],
suite_category_images: data.suite_category_images || [],
});
export const hotelUnitService = {
async getAllUnits(): Promise<HotelUnit[]> {
const { data, error } = await supabase.from('hotel_units').select('*').order('name');
if (error) throw new Error(error.message);
return data.map(fromSupabase);
},
async getUnitsByBrand(brandId: number): Promise<HotelUnit[]> {
const { data, error } = await supabase.from('hotel_units').select('*').eq('brand_id', brandId).order('name');
if (error) throw new Error(error.message);
return data.map(fromSupabase);
},
async addUnit(newUnit: Omit<HotelUnit, 'id'>): Promise<HotelUnit> {
const payload: Database['public']['Tables']['hotel_units']['Insert'] = {
name: newUnit.name,
brand_id: newUnit.brandId,
visible_suite_categories: newUnit.visible_suite_categories,
// FIX: Cast to 'unknown' first to satisfy TypeScript's strict type checking for the Json type.
suite_category_images: newUnit.suite_category_images as unknown as Json,
};
const { data, error } = await supabase
.from('hotel_units')
.insert([payload])
.select()
.single();
if (error) throw new Error(error.message);
return fromSupabase(data);
},
async updateUnit(updatedUnit: HotelUnit): Promise<HotelUnit> {
const payload: Database['public']['Tables']['hotel_units']['Update'] = {
name: updatedUnit.name,
brand_id: updatedUnit.brandId,
visible_suite_categories: updatedUnit.visible_suite_categories,
// FIX: Cast to 'unknown' first to satisfy TypeScript's strict type checking for the Json type.
suite_category_images: updatedUnit.suite_category_images as unknown as Json,
};
const { data, error } = await supabase
.from('hotel_units')
.update(payload)
.eq('id', updatedUnit.id)
.select()
.single();
if (error) throw new Error(error.message);
return fromSupabase(data);
},
async deleteUnit(unitId: number): Promise<void> {
const { error } = await supabase.from('hotel_units').delete().eq('id', unitId);
if (error) throw new Error(error.message);
},
};

View File

@ -0,0 +1,87 @@
import { supabase } from '../supabaseClient.ts';
import type { PricingData, PricingRow, Brand, Database } from '../types.ts';
const DAY_RANGES = ["SEGUNDA A QUARTA", "QUINTA A DOMINGO"];
// Transforma uma lista de linhas de preço do DB em um objeto aninhado para a UI
const transformToPricingData = (rows: PricingRow[]): PricingData => {
const pricingData: PricingData = {};
for (const row of rows) {
if (!pricingData[row.day_range]) {
pricingData[row.day_range] = {};
}
if (!pricingData[row.day_range][row.suite_category]) {
pricingData[row.day_range][row.suite_category] = {};
}
pricingData[row.day_range][row.suite_category][row.duration] = row.price;
}
return pricingData;
};
export const pricingService = {
// Busca os dados de preço de uma marca e formata para a UI
async getPricingData(brandId: number): Promise<PricingData> {
const { data, error } = await supabase
.from('pricing')
.select('*')
.eq('brand_id', brandId);
if (error) throw new Error(error.message);
return transformToPricingData((data as PricingRow[]) || []);
},
// Salva (upsert) todos os dados de preço de uma marca
async savePricingData(brandId: number, data: PricingData): Promise<void> {
const rowsToUpsert: Database['public']['Tables']['pricing']['Insert'][] = [];
for (const dayRange in data) {
for (const category in data[dayRange]) {
for (const duration in data[dayRange][category]) {
rowsToUpsert.push({
brand_id: brandId,
day_range: dayRange,
suite_category: category,
duration: duration,
price: data[dayRange][category][duration]
});
}
}
}
if (rowsToUpsert.length > 0) {
const { error } = await supabase.from('pricing').upsert(rowsToUpsert, { onConflict: 'brand_id,day_range,suite_category,duration' });
if (error) throw new Error(error.message);
}
},
// Garante que a estrutura de preços exista para uma marca, criando linhas com preço 0 se necessário
async syncPricingForBrand(brand: Brand): Promise<void> {
const rowsToCreate: Database['public']['Tables']['pricing']['Insert'][] = [];
DAY_RANGES.forEach(dayRange => {
brand.suite_categories.forEach(category => {
brand.stay_durations.forEach(duration => {
rowsToCreate.push({
brand_id: brand.id,
day_range: dayRange,
suite_category: category,
duration: duration,
price: 0 // Default price
});
});
});
});
if (rowsToCreate.length > 0) {
// 'ignoreDuplicates: true' fará com que o upsert não retorne erro se a linha já existir
const { error } = await supabase.from('pricing').upsert(rowsToCreate, { onConflict: 'brand_id,day_range,suite_category,duration', ignoreDuplicates: true });
if (error) {
console.error("Error syncing pricing data:", error);
throw new Error(error.message);
}
}
// Opcional: remover preços de categorias/durações que não existem mais
// Esta parte pode ser complexa e requer cuidado para não remover dados indevidamente.
// Por enquanto, apenas adicionamos novas entradas.
}
};

View File

@ -0,0 +1,92 @@
import { supabase } from '../supabaseClient.ts';
import type { Suite, Database } from '../types.ts';
// Mapeia os dados do Supabase (snake_case) para o tipo Suite (camelCase)
const fromSupabase = (data: any): Suite => ({
id: data.id,
api_id: data.api_id,
name: data.name,
category: data.category,
unitIds: data.unit_ids || [],
});
export const suiteService = {
async getAllSuites(): Promise<Suite[]> {
const { data, error } = await supabase.from('suites').select('*').order('name');
if (error) throw new Error(error.message);
return data.map(fromSupabase);
},
async addSuite(newSuite: Omit<Suite, 'id'>): Promise<Suite> {
const payload: Database['public']['Tables']['suites']['Insert'] = {
api_id: newSuite.api_id,
name: newSuite.name,
category: newSuite.category,
unit_ids: newSuite.unitIds,
};
const { data, error } = await supabase
.from('suites')
.insert([payload])
.select()
.single();
if (error) throw new Error(error.message);
return fromSupabase(data);
},
async updateSuite(updatedSuite: Suite): Promise<Suite> {
const payload: Database['public']['Tables']['suites']['Update'] = {
api_id: updatedSuite.api_id,
name: updatedSuite.name,
category: updatedSuite.category,
unit_ids: updatedSuite.unitIds,
};
const { data, error } = await supabase
.from('suites')
.update(payload)
.eq('id', updatedSuite.id)
.select()
.single();
if (error) throw new Error(error.message);
return fromSupabase(data);
},
async deleteSuite(suiteId: number): Promise<void> {
const { error } = await supabase.from('suites').delete().eq('id', suiteId);
if (error) throw new Error(error.message);
},
async getRandomSuiteApiIdFromCategory(category: string, unitId: number): Promise<number | null> {
// ABORDAGEM ROBUSTA: Primeiro, busca todas as suítes da categoria.
const { data: suitesInCategory, error } = await supabase
.from('suites')
.select('api_id, unit_ids')
.eq('category', category);
if (error) {
console.error("Erro ao buscar suítes por categoria:", error);
throw new Error(error.message);
}
if (!suitesInCategory || suitesInCategory.length === 0) {
console.warn(`Nenhuma suíte encontrada no banco de dados para a categoria '${category}'.`);
return null;
}
// Segundo, filtra as suítes no código para encontrar as disponíveis na unidade.
// Isso é mais confiável do que usar filtros complexos de array na query.
const availableSuites = suitesInCategory.filter(suite =>
suite.unit_ids && suite.unit_ids.includes(unitId)
);
if (availableSuites.length === 0) {
console.warn(`Nenhuma suíte da categoria '${category}' foi encontrada como disponível para a unidade com ID '${unitId}'. Verifique a coluna 'unit_ids' das suítes cadastradas.`);
return null;
}
// Terceiro, seleciona uma suíte aleatória da lista de disponíveis.
const randomIndex = Math.floor(Math.random() * availableSuites.length);
return availableSuites[randomIndex].api_id;
},
};

View File

@ -0,0 +1,11 @@
import { createClient } from '@supabase/supabase-js';
import { Database } from './types.ts';
// ATENÇÃO: Substitua pelos dados do seu projeto Supabase.
// Você encontra esses valores no painel do seu projeto em:
// Settings -> API
const SUPABASE_URL = 'https://qdpzlxqsjbyxcajinixi.supabase.co'; // Ex: 'https://xxxxxxxx.supabase.co'
const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InFkcHpseHFzamJ5eGNhamluaXhpIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTE2MjMyNTYsImV4cCI6MjA2NzE5OTI1Nn0.9qNMBqsE_6WmCm4Qcgbaf1xNtHdU5EV4w9kYeWT3Xfw'; // A chave anônima (public)
export const supabase = createClient<Database>(SUPABASE_URL, SUPABASE_ANON_KEY);

View File

@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

View File

@ -0,0 +1,234 @@
// Estrutura para Marcas (Redes)
export interface Brand {
id: number;
name: string;
suite_categories: string[];
stay_durations: string[];
}
// Estrutura para associar categoria de suíte com imagem
export interface SuiteCategoryImage {
category: string;
imageUrls: string[]; // Agora um array para suportar múltiplas imagens
}
// Estrutura para Unidades de Hotel, agora com relacionamento de marca e imagens
export interface HotelUnit {
id: number;
name: string;
brandId: number;
visible_suite_categories: string[];
suite_category_images: SuiteCategoryImage[] | null;
}
// Modelo para Itens Extras
export interface ExtraItem {
id: string;
title: string;
description?: string;
price: number;
image?: string;
category?: string;
tag?: string;
active: boolean;
order: number;
}
export interface FormDataModel {
nome: string;
checkInDateTime: string;
telefone: string;
email: string;
cpf: string;
observacao: string;
selectedBrand: string; // ID da marca
selectedUnit: string; // ID da unidade
selectedCategory: string; // Nome da categoria
stayDuration: string; // Nome da duração
selectedExtras: string[]; // IDs dos extras selecionados
}
// Payload para a API (webhook N8N) - AGORA EM SNAKE_CASE
export interface ApiPostPayload {
suite_id: number;
data_inicio: string; // Formato ISO
nome: string;
telefone: string;
email: string;
cpf: string;
integracao_id: string;
modo: number;
observacoes: string;
marca: string; // Nome da marca
unidade: string; // Nome da unidade
categoria: string; // Nome da categoria
permanencia: string; // Nome da duração
valor: number; // Preço da reserva
extras_selecionados?: ExtraItem[]; // Lista de objetos extras
}
export type SubmissionStatusType = 'success' | 'error' | null;
export interface SubmissionState {
message: string;
type: SubmissionStatusType;
pix?: {
qrCodeValue: string;
copyPasteCode: string;
txid: string; // The transaction ID for payment verification
};
}
// Resposta esperada do webhook
export interface N8nApiResponse {
pixCopiaECola: string;
pixUrl: string;
txid: string; // The transaction ID for payment verification
}
// Estrutura para Suítes
export interface Suite {
id: number; // PK do banco de dados
api_id: number; // ID para a API externa
name: string;
category: string;
unitIds: number[]; // IDs das unidades onde a suíte está disponível
}
// Estrutura para a tabela de preços, usada pela UI
export interface PricingData {
[dayRange: string]: {
[category: string]: {
[duration: string]: number;
};
};
}
// Estrutura para uma linha da tabela de preços no banco de dados
export interface PricingRow {
id: number;
brand_id: number;
day_range: string;
suite_category: string;
duration: string;
price: number;
}
// --- Supabase Database Schema ---
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export type Database = {
public: {
Tables: {
brands: {
Row: {
id: number
name: string
suite_categories: string[]
stay_durations: string[]
}
Insert: {
id?: number
name: string
suite_categories: string[]
stay_durations: string[]
}
Update: {
id?: number
name?: string
suite_categories?: string[]
stay_durations?: string[]
}
Relationships: []
}
hotel_units: {
Row: {
id: number
name: string
brand_id: number
visible_suite_categories: string[] | null
suite_category_images: Json | null
}
Insert: {
id?: number
name: string
brand_id: number
visible_suite_categories?: string[] | null
suite_category_images?: Json | null
}
Update: {
id?: number
name?: string
brand_id?: number
visible_suite_categories?: string[] | null
suite_category_images?: Json | null
}
Relationships: []
}
suites: {
Row: {
id: number
api_id: number
name: string
category: string
unit_ids: number[] | null
}
Insert: {
id?: number
api_id: number
name: string
category: string
unit_ids?: number[] | null
}
Update: {
id?: number
api_id?: number
name?: string
category?: string
unit_ids?: number[] | null
}
Relationships: []
}
pricing: {
Row: {
id: number
brand_id: number
day_range: string
suite_category: string
duration: string
price: number
}
Insert: {
id?: number
brand_id: number
day_range: string
suite_category: string
duration: string
price: number
}
Update: {
id?: number
brand_id?: number
day_range?: string
suite_category?: string
duration?: string
price?: number
}
Relationships: []
}
}
Views: {}
Functions: {}
Enums: {}
CompositeTypes: {}
}
}

View File

@ -0,0 +1,24 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react(), tailwindcss()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});