chore: inicializa repo com POC como referencia
This commit is contained in:
commit
3cdbaadb9b
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal 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
|
||||||
24
_poc-reference/hotel-1001-noites-prime---reserva/.gitignore
vendored
Executable file
24
_poc-reference/hotel-1001-noites-prime---reserva/.gitignore
vendored
Executable 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?
|
||||||
730
_poc-reference/hotel-1001-noites-prime---reserva/App.tsx
Executable file
730
_poc-reference/hotel-1001-noites-prime---reserva/App.tsx
Executable 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">
|
||||||
|
© {new Date().getFullYear()} {appConfig.title} • Experiência Exclusiva
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
20
_poc-reference/hotel-1001-noites-prime---reserva/README.md
Executable file
20
_poc-reference/hotel-1001-noites-prime---reserva/README.md
Executable 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`
|
||||||
881
_poc-reference/hotel-1001-noites-prime---reserva/components/AdminPage.tsx
Executable file
881
_poc-reference/hotel-1001-noites-prime---reserva/components/AdminPage.tsx
Executable 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;
|
||||||
117
_poc-reference/hotel-1001-noites-prime---reserva/components/FormField.tsx
Executable file
117
_poc-reference/hotel-1001-noites-prime---reserva/components/FormField.tsx
Executable 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;
|
||||||
95
_poc-reference/hotel-1001-noites-prime---reserva/components/SelectField.tsx
Executable file
95
_poc-reference/hotel-1001-noites-prime---reserva/components/SelectField.tsx
Executable 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;
|
||||||
94
_poc-reference/hotel-1001-noites-prime---reserva/components/ui/button.tsx
Executable file
94
_poc-reference/hotel-1001-noites-prime---reserva/components/ui/button.tsx
Executable 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 };
|
||||||
12
_poc-reference/hotel-1001-noites-prime---reserva/components/ui/confetti.tsx
Executable file
12
_poc-reference/hotel-1001-noites-prime---reserva/components/ui/confetti.tsx
Executable 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>
|
||||||
|
);
|
||||||
41
_poc-reference/hotel-1001-noites-prime---reserva/index.css
Executable file
41
_poc-reference/hotel-1001-noites-prime---reserva/index.css
Executable 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;
|
||||||
|
}
|
||||||
53
_poc-reference/hotel-1001-noites-prime---reserva/index.html
Executable file
53
_poc-reference/hotel-1001-noites-prime---reserva/index.html
Executable 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>
|
||||||
15
_poc-reference/hotel-1001-noites-prime---reserva/index.tsx
Executable file
15
_poc-reference/hotel-1001-noites-prime---reserva/index.tsx
Executable 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>
|
||||||
|
);
|
||||||
7
_poc-reference/hotel-1001-noites-prime---reserva/lib/utils.ts
Executable file
7
_poc-reference/hotel-1001-noites-prime---reserva/lib/utils.ts
Executable 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));
|
||||||
|
}
|
||||||
6
_poc-reference/hotel-1001-noites-prime---reserva/metadata.json
Executable file
6
_poc-reference/hotel-1001-noites-prime---reserva/metadata.json
Executable 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
2733
_poc-reference/hotel-1001-noites-prime---reserva/package-lock.json
generated
Executable file
2733
_poc-reference/hotel-1001-noites-prime---reserva/package-lock.json
generated
Executable file
File diff suppressed because it is too large
Load Diff
32
_poc-reference/hotel-1001-noites-prime---reserva/package.json
Executable file
32
_poc-reference/hotel-1001-noites-prime---reserva/package.json
Executable 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
82
_poc-reference/hotel-1001-noites-prime---reserva/services/apiService.ts
Executable file
82
_poc-reference/hotel-1001-noites-prime---reserva/services/apiService.ts
Executable 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.`);
|
||||||
|
}
|
||||||
|
};
|
||||||
63
_poc-reference/hotel-1001-noites-prime---reserva/services/brandService.ts
Executable file
63
_poc-reference/hotel-1001-noites-prime---reserva/services/brandService.ts
Executable 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
63
_poc-reference/hotel-1001-noites-prime---reserva/services/extraService.ts
Executable file
63
_poc-reference/hotel-1001-noites-prime---reserva/services/extraService.ts
Executable 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
87
_poc-reference/hotel-1001-noites-prime---reserva/services/pricingService.ts
Executable file
87
_poc-reference/hotel-1001-noites-prime---reserva/services/pricingService.ts
Executable 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.
|
||||||
|
}
|
||||||
|
};
|
||||||
92
_poc-reference/hotel-1001-noites-prime---reserva/services/suiteService.ts
Executable file
92
_poc-reference/hotel-1001-noites-prime---reserva/services/suiteService.ts
Executable 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
11
_poc-reference/hotel-1001-noites-prime---reserva/supabaseClient.ts
Executable file
11
_poc-reference/hotel-1001-noites-prime---reserva/supabaseClient.ts
Executable 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);
|
||||||
29
_poc-reference/hotel-1001-noites-prime---reserva/tsconfig.json
Executable file
29
_poc-reference/hotel-1001-noites-prime---reserva/tsconfig.json
Executable 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
|
||||||
|
}
|
||||||
|
}
|
||||||
234
_poc-reference/hotel-1001-noites-prime---reserva/types.ts
Executable file
234
_poc-reference/hotel-1001-noites-prime---reserva/types.ts
Executable 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: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
_poc-reference/hotel-1001-noites-prime---reserva/vite.config.ts
Executable file
24
_poc-reference/hotel-1001-noites-prime---reserva/vite.config.ts
Executable 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, '.'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user