114
App.tsx
|
|
@ -1,114 +0,0 @@
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { Navbar } from './components/Navbar';
|
|
||||||
import { Home } from './pages/Home';
|
|
||||||
import { Dashboard } from './pages/Dashboard';
|
|
||||||
import { Login } from './pages/Login';
|
|
||||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
|
||||||
import { DataProvider } from './contexts/DataContext';
|
|
||||||
import { Construction } from 'lucide-react'; // Placeholder icon
|
|
||||||
|
|
||||||
const AppContent: React.FC = () => {
|
|
||||||
const { user } = useAuth();
|
|
||||||
const [currentPage, setCurrentPage] = useState('home');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (user && currentPage === 'login') {
|
|
||||||
setCurrentPage('dashboard');
|
|
||||||
}
|
|
||||||
}, [user, currentPage]);
|
|
||||||
|
|
||||||
// Simple Router Logic
|
|
||||||
const renderPage = () => {
|
|
||||||
if (currentPage === 'home') return <Home onEnter={() => setCurrentPage(user ? 'dashboard' : 'login')} />;
|
|
||||||
if (currentPage === 'login') return user ? <Dashboard /> : <Login />;
|
|
||||||
|
|
||||||
// Protected Routes Check
|
|
||||||
if (!user) return <Login />;
|
|
||||||
|
|
||||||
switch (currentPage) {
|
|
||||||
case 'dashboard':
|
|
||||||
case 'events':
|
|
||||||
return <Dashboard initialView="list" />;
|
|
||||||
|
|
||||||
case 'request-event':
|
|
||||||
return <Dashboard initialView="create" />;
|
|
||||||
|
|
||||||
case 'uploads':
|
|
||||||
return <Dashboard initialView="uploads" />;
|
|
||||||
|
|
||||||
// Placeholder routes for future implementation
|
|
||||||
case 'team':
|
|
||||||
case 'finance':
|
|
||||||
case 'settings':
|
|
||||||
case 'albums':
|
|
||||||
case 'calendar':
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-white pt-32 px-4 text-center fade-in">
|
|
||||||
<div className="max-w-md mx-auto bg-gray-50 p-12 rounded-lg border border-gray-100 shadow-sm">
|
|
||||||
<div className="mx-auto w-16 h-16 bg-gray-200 rounded-full flex items-center justify-center mb-6 text-gray-400">
|
|
||||||
<Construction size={32} />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-serif font-bold mb-3 text-brand-black capitalize">
|
|
||||||
{currentPage === 'team' ? 'Equipe & Fotógrafos' :
|
|
||||||
currentPage === 'finance' ? 'Financeiro' :
|
|
||||||
currentPage === 'calendar' ? 'Agenda' :
|
|
||||||
currentPage}
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-500 mb-8 leading-relaxed">
|
|
||||||
Esta funcionalidade está em desenvolvimento e estará disponível em breve no seu painel.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage('dashboard')}
|
|
||||||
className="px-6 py-2 bg-brand-black text-white rounded-sm hover:bg-gray-800 transition-colors font-medium text-sm"
|
|
||||||
>
|
|
||||||
Voltar ao Dashboard
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
default:
|
|
||||||
// Fallback
|
|
||||||
return <Dashboard initialView="list" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-white">
|
|
||||||
<Navbar
|
|
||||||
onNavigate={setCurrentPage}
|
|
||||||
currentPage={currentPage}
|
|
||||||
/>
|
|
||||||
<main>
|
|
||||||
{renderPage()}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Footer only on Home */}
|
|
||||||
{currentPage === 'home' && (
|
|
||||||
<footer className="bg-white border-t border-gray-100 py-12">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 flex flex-col md:flex-row justify-between items-center text-sm text-gray-500">
|
|
||||||
<p>© 2024 PhotumManager. Todos os direitos reservados.</p>
|
|
||||||
<div className="flex space-x-6 mt-4 md:mt-0">
|
|
||||||
<a href="#" className="hover:text-brand-black">Política de Privacidade</a>
|
|
||||||
<a href="#" className="hover:text-brand-black">Termos de Uso</a>
|
|
||||||
<a href="#" className="hover:text-brand-black">Instagram</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<AuthProvider>
|
|
||||||
<DataProvider>
|
|
||||||
<AppContent />
|
|
||||||
</DataProvider>
|
|
||||||
</AuthProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
20
README.md
|
|
@ -1,20 +0,0 @@
|
||||||
<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 appp
|
|
||||||
|
|
||||||
This contains everything you need to run your app locally.
|
|
||||||
|
|
||||||
View your app in AI Studio: https://ai.studio/apps/drive/1Rd4siG8Ot2v0r3XhTNfIYrylHVUvYJmm
|
|
||||||
|
|
||||||
## 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`
|
|
||||||
0
backend/.gitkeep
Normal file
|
|
@ -1,405 +0,0 @@
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { EventType, EventStatus, Address } from '../types';
|
|
||||||
import { Input, Select } from './Input';
|
|
||||||
import { Button } from './Button';
|
|
||||||
import { MapPin, Upload, Plus, X, Check, FileText, ExternalLink, Search, CheckCircle } from 'lucide-react';
|
|
||||||
import { searchLocationWithGemini, GeoResult } from '../services/genaiService';
|
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
|
||||||
import { UserRole } from '../types';
|
|
||||||
|
|
||||||
interface EventFormProps {
|
|
||||||
onCancel: () => void;
|
|
||||||
onSubmit: (data: any) => void;
|
|
||||||
initialData?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EventForm: React.FC<EventFormProps> = ({ onCancel, onSubmit, initialData }) => {
|
|
||||||
const { user } = useAuth();
|
|
||||||
const [activeTab, setActiveTab] = useState<'details' | 'location' | 'briefing' | 'files'>('details');
|
|
||||||
const [addressQuery, setAddressQuery] = useState('');
|
|
||||||
const [addressResults, setAddressResults] = useState<GeoResult[]>([]);
|
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
|
||||||
const [showToast, setShowToast] = useState(false);
|
|
||||||
|
|
||||||
// Default State or Initial Data
|
|
||||||
const [formData, setFormData] = useState(initialData || {
|
|
||||||
name: '',
|
|
||||||
date: '',
|
|
||||||
time: '',
|
|
||||||
type: '',
|
|
||||||
status: EventStatus.PLANNING,
|
|
||||||
address: { street: '', number: '', city: '', state: '', zip: '', mapLink: '' } as Address,
|
|
||||||
briefing: '',
|
|
||||||
contacts: [{ name: '', role: '', phone: '' }],
|
|
||||||
files: [] as File[],
|
|
||||||
coverImage: 'https://images.unsplash.com/photo-1511795409834-ef04bbd61622?ixlib=rb-1.2.1&auto=format&fit=crop&w=800&q=80' // Default
|
|
||||||
});
|
|
||||||
|
|
||||||
const isClientRequest = user?.role === UserRole.EVENT_OWNER;
|
|
||||||
const formTitle = initialData
|
|
||||||
? "Editar Evento"
|
|
||||||
: (isClientRequest ? "Solicitar Orçamento/Evento" : "Cadastrar Novo Evento");
|
|
||||||
const submitLabel = initialData ? "Salvar Alterações" : (isClientRequest ? "Enviar Solicitação" : "Criar Evento");
|
|
||||||
|
|
||||||
// Address Autocomplete Logic using Gemini
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(async () => {
|
|
||||||
if (addressQuery.length > 4) { // Increased threshold to avoid spamming API
|
|
||||||
setIsSearching(true);
|
|
||||||
const results = await searchLocationWithGemini(addressQuery);
|
|
||||||
setAddressResults(results);
|
|
||||||
setIsSearching(false);
|
|
||||||
} else {
|
|
||||||
setAddressResults([]);
|
|
||||||
}
|
|
||||||
}, 800); // Increased debounce for API efficiency
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [addressQuery]);
|
|
||||||
|
|
||||||
const handleAddressSelect = (addr: GeoResult) => {
|
|
||||||
setFormData((prev: any) => ({
|
|
||||||
...prev,
|
|
||||||
address: {
|
|
||||||
street: addr.street,
|
|
||||||
number: addr.number,
|
|
||||||
city: addr.city,
|
|
||||||
state: addr.state,
|
|
||||||
zip: addr.zip,
|
|
||||||
mapLink: addr.mapLink
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
setAddressQuery('');
|
|
||||||
setAddressResults([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addContact = () => {
|
|
||||||
setFormData((prev: any) => ({
|
|
||||||
...prev,
|
|
||||||
contacts: [...prev.contacts, { name: '', role: '', phone: '' }]
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeContact = (index: number) => {
|
|
||||||
const newContacts = [...formData.contacts];
|
|
||||||
newContacts.splice(index, 1);
|
|
||||||
setFormData((prev: any) => ({ ...prev, contacts: newContacts }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
if (e.target.files) {
|
|
||||||
setFormData((prev: any) => ({
|
|
||||||
...prev,
|
|
||||||
files: [...prev.files, ...Array.from(e.target.files || [])]
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
// Show toast
|
|
||||||
setShowToast(true);
|
|
||||||
// Call original submit after small delay for visual effect or immediately
|
|
||||||
setTimeout(() => {
|
|
||||||
onSubmit(formData);
|
|
||||||
}, 1000);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-xl overflow-hidden max-w-4xl mx-auto border border-gray-100 slide-up relative">
|
|
||||||
|
|
||||||
{/* Success Toast */}
|
|
||||||
{showToast && (
|
|
||||||
<div className="absolute top-4 right-4 z-50 bg-brand-black text-white px-6 py-4 rounded shadow-2xl flex items-center space-x-3 fade-in">
|
|
||||||
<CheckCircle className="text-brand-gold h-6 w-6" />
|
|
||||||
<div>
|
|
||||||
<h4 className="font-bold text-sm">Sucesso!</h4>
|
|
||||||
<p className="text-xs text-gray-300">As informações foram salvas.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Form Header */}
|
|
||||||
<div className="bg-gray-50 border-b px-8 py-6 flex justify-between items-center">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-serif text-brand-black">{formTitle}</h2>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
|
||||||
{isClientRequest
|
|
||||||
? "Preencha os detalhes do seu sonho. Nossa equipe analisará em breve."
|
|
||||||
: "Preencha as informações técnicas do evento."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
{['details', 'location', 'briefing', 'files'].map((tab, idx) => (
|
|
||||||
<div key={tab} className={`flex flex-col items-center ${activeTab === tab ? 'opacity-100' : 'opacity-40'}`}>
|
|
||||||
<span className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold mb-1 ${activeTab === tab ? 'bg-brand-black text-white' : 'bg-gray-200 text-gray-600'}`}>
|
|
||||||
{idx + 1}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 min-h-[500px]">
|
|
||||||
{/* Sidebar Navigation for Form */}
|
|
||||||
<div className="col-span-1 border-r border-gray-100 bg-gray-50/50 p-4 space-y-2">
|
|
||||||
{[
|
|
||||||
{ id: 'details', label: 'Detalhes & Data' },
|
|
||||||
{ id: 'location', label: 'Localização' },
|
|
||||||
{ id: 'briefing', label: isClientRequest ? 'Seus Desejos' : 'Briefing & Equipe' },
|
|
||||||
{ id: 'files', label: 'Inspirações' }
|
|
||||||
].map(item => (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
onClick={() => setActiveTab(item.id as any)}
|
|
||||||
className={`w-full text-left px-4 py-3 rounded-sm text-sm font-medium transition-colors ${
|
|
||||||
activeTab === item.id
|
|
||||||
? 'bg-white shadow-sm text-brand-gold border-l-4 border-brand-gold'
|
|
||||||
: 'text-gray-500 hover:bg-gray-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form Content */}
|
|
||||||
<div className="col-span-3 p-8">
|
|
||||||
|
|
||||||
{activeTab === 'details' && (
|
|
||||||
<div className="space-y-6 fade-in">
|
|
||||||
<div className="grid grid-cols-1 gap-6">
|
|
||||||
<Input
|
|
||||||
label="Nome do Evento (Opcional)"
|
|
||||||
placeholder="Ex: Casamento Silva & Souza"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => setFormData({...formData, name: e.target.value})}
|
|
||||||
/>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Input
|
|
||||||
label="Data Pretendida"
|
|
||||||
type="date"
|
|
||||||
value={formData.date}
|
|
||||||
onChange={(e) => setFormData({...formData, date: e.target.value})}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Horário Aproximado"
|
|
||||||
type="time"
|
|
||||||
value={formData.time}
|
|
||||||
onChange={(e) => setFormData({...formData, time: e.target.value})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
label="Tipo de Evento"
|
|
||||||
options={Object.values(EventType).map(t => ({ value: t, label: t }))}
|
|
||||||
value={formData.type}
|
|
||||||
onChange={(e) => setFormData({...formData, type: e.target.value})}
|
|
||||||
/>
|
|
||||||
{/* Cover Image Upload (Basic URL input for now) */}
|
|
||||||
<Input
|
|
||||||
label="URL Imagem de Capa"
|
|
||||||
placeholder="https://..."
|
|
||||||
value={formData.coverImage}
|
|
||||||
onChange={(e) => setFormData({...formData, coverImage: e.target.value})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end mt-8">
|
|
||||||
<Button onClick={() => setActiveTab('location')}>Próximo: Localização</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'location' && (
|
|
||||||
<div className="space-y-6 fade-in">
|
|
||||||
<div className="relative">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1 tracking-wide uppercase text-xs">
|
|
||||||
Busca Google Maps (Powered by Gemini)
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-sm focus:outline-none focus:ring-1 focus:ring-brand-gold focus:border-brand-gold transition-colors pr-10"
|
|
||||||
placeholder="Digite o nome do local ou endereço..."
|
|
||||||
value={addressQuery}
|
|
||||||
onChange={(e) => setAddressQuery(e.target.value)}
|
|
||||||
/>
|
|
||||||
<div className="absolute right-3 top-2.5 text-gray-400">
|
|
||||||
{isSearching ? (
|
|
||||||
<div className="animate-spin h-5 w-5 border-2 border-brand-gold rounded-full border-t-transparent"></div>
|
|
||||||
) : (
|
|
||||||
<Search size={20} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{addressResults.length > 0 && (
|
|
||||||
<ul className="absolute z-10 w-full bg-white border mt-1 shadow-lg rounded-sm max-h-64 overflow-y-auto">
|
|
||||||
{addressResults.map((addr, idx) => (
|
|
||||||
<li
|
|
||||||
key={idx}
|
|
||||||
className="px-4 py-3 hover:bg-gray-50 cursor-pointer text-sm border-b border-gray-50 last:border-0"
|
|
||||||
onClick={() => handleAddressSelect(addr)}
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div className="flex items-start">
|
|
||||||
<MapPin size={16} className="mt-0.5 mr-2 text-brand-gold flex-shrink-0"/>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-800">{addr.description}</p>
|
|
||||||
<p className="text-xs text-gray-500 mt-0.5">{addr.city}, {addr.state}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{addr.mapLink && (
|
|
||||||
<span className="flex items-center text-[10px] text-blue-600 bg-blue-50 px-2 py-1 rounded ml-2">
|
|
||||||
<img src="https://www.google.com/images/branding/product/ico/maps15_bnuw3a_32dp.png" alt="Maps" className="w-3 h-3 mr-1"/>
|
|
||||||
Maps
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div className="col-span-2">
|
|
||||||
<Input label="Rua" value={formData.address.street} readOnly />
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
label="Número"
|
|
||||||
placeholder="123"
|
|
||||||
value={formData.address.number}
|
|
||||||
onChange={(e) => setFormData({...formData, address: {...formData.address, number: e.target.value}})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Input label="Cidade" value={formData.address.city} readOnly />
|
|
||||||
<Input label="Estado" value={formData.address.state} readOnly />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{formData.address.mapLink && (
|
|
||||||
<div className="bg-gray-50 p-3 rounded border border-gray-200 flex items-center justify-between">
|
|
||||||
<span className="text-xs text-gray-500 flex items-center">
|
|
||||||
<Check size={14} className="mr-1 text-green-500"/>
|
|
||||||
Localização verificada via Google Maps
|
|
||||||
</span>
|
|
||||||
<a href={formData.address.mapLink} target="_blank" rel="noreferrer" className="text-xs text-brand-gold flex items-center hover:underline">
|
|
||||||
Ver no mapa <ExternalLink size={12} className="ml-1"/>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-between mt-8">
|
|
||||||
<Button variant="outline" onClick={() => setActiveTab('details')}>Voltar</Button>
|
|
||||||
<Button onClick={() => setActiveTab('briefing')}>Próximo</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'briefing' && (
|
|
||||||
<div className="space-y-6 fade-in">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1 tracking-wide uppercase text-xs">
|
|
||||||
{isClientRequest ? "Conte-nos sobre o seu sonho" : "Briefing Técnico"}
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
className="w-full border border-gray-300 rounded-sm p-3 focus:outline-none focus:border-brand-gold h-32 text-sm"
|
|
||||||
placeholder={isClientRequest ? "Qual o estilo do casamento? Quais fotos são indispensáveis? Fale um pouco sobre vocês..." : "Instruções técnicas..."}
|
|
||||||
value={formData.briefing}
|
|
||||||
onChange={(e) => setFormData({...formData, briefing: e.target.value})}
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between items-center mb-2">
|
|
||||||
<label className="text-sm font-medium text-gray-700 tracking-wide uppercase text-xs">Contatos / Responsáveis</label>
|
|
||||||
<button onClick={addContact} className="text-xs text-brand-gold font-bold hover:underline flex items-center">
|
|
||||||
<Plus size={14} className="mr-1"/> Adicionar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{formData.contacts.map((contact: any, idx: number) => (
|
|
||||||
<div key={idx} className="flex space-x-2 items-start">
|
|
||||||
<Input
|
|
||||||
label={idx === 0 ? "Nome" : ""}
|
|
||||||
placeholder="Nome"
|
|
||||||
value={contact.name}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newContacts = [...formData.contacts];
|
|
||||||
newContacts[idx].name = e.target.value;
|
|
||||||
setFormData({...formData, contacts: newContacts});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={idx === 0 ? "Papel" : ""}
|
|
||||||
placeholder="Ex: Cerimonialista"
|
|
||||||
value={contact.role}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newContacts = [...formData.contacts];
|
|
||||||
newContacts[idx].role = e.target.value;
|
|
||||||
setFormData({...formData, contacts: newContacts});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => removeContact(idx)}
|
|
||||||
className={`mt-1 p-2 text-gray-400 hover:text-red-500 ${idx === 0 ? 'mt-7' : ''}`}
|
|
||||||
>
|
|
||||||
<X size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between mt-8">
|
|
||||||
<Button variant="outline" onClick={() => setActiveTab('location')}>Voltar</Button>
|
|
||||||
<Button onClick={() => setActiveTab('files')}>Próximo</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'files' && (
|
|
||||||
<div className="space-y-6 fade-in">
|
|
||||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-10 text-center hover:bg-gray-50 transition-colors relative">
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
|
||||||
onChange={handleFileUpload}
|
|
||||||
/>
|
|
||||||
<Upload size={40} className="mx-auto text-gray-400 mb-4" />
|
|
||||||
<p className="text-sm text-gray-600 font-medium">
|
|
||||||
{isClientRequest ? "Anexe referências visuais (Moodboard)" : "Anexe contratos e cronogramas"}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-400 mt-1">PDF, JPG, PNG (Max 10MB)</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{formData.files.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="text-sm font-medium text-gray-700">Arquivos Selecionados:</h4>
|
|
||||||
{formData.files.map((file: any, idx: number) => (
|
|
||||||
<div key={idx} className="flex items-center justify-between p-3 bg-gray-50 rounded border border-gray-100">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<FileText size={18} className="text-brand-gold mr-3"/>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">{file.name}</p>
|
|
||||||
<p className="text-xs text-gray-400">{(file.size / 1024).toFixed(1)} KB</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Check size={16} className="text-green-500" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-between mt-8">
|
|
||||||
<Button variant="outline" onClick={() => setActiveTab('briefing')}>Voltar</Button>
|
|
||||||
<Button onClick={handleSubmit} variant="secondary">{submitLabel}</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
||||||
label: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Input: React.FC<InputProps> = ({ label, error, className = '', ...props }) => {
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1 tracking-wide uppercase text-xs">
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
className={`w-full px-4 py-2 border rounded-sm focus:outline-none focus:ring-1 focus:ring-brand-gold focus:border-brand-gold transition-colors
|
|
||||||
${error ? 'border-red-500' : 'border-gray-300'}
|
|
||||||
${className}`}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
{error && <span className="text-xs text-red-500 mt-1">{error}</span>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
|
||||||
label: string;
|
|
||||||
options: { value: string; label: string }[];
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Select: React.FC<SelectProps> = ({ label, options, error, className = '', ...props }) => {
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1 tracking-wide uppercase text-xs">
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
className={`w-full px-4 py-2 border rounded-sm focus:outline-none focus:ring-1 focus:ring-brand-gold focus:border-brand-gold transition-colors appearance-none bg-white
|
|
||||||
${error ? 'border-red-500' : 'border-gray-300'}
|
|
||||||
${className}`}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<option value="" disabled>Selecione uma opção</option>
|
|
||||||
{options.map(opt => (
|
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{error && <span className="text-xs text-red-500 mt-1">{error}</span>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,184 +0,0 @@
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { UserRole } from '../types';
|
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
|
||||||
import { Menu, X, LogOut } from 'lucide-react';
|
|
||||||
import { Button } from './Button';
|
|
||||||
|
|
||||||
interface NavbarProps {
|
|
||||||
onNavigate: (page: string) => void;
|
|
||||||
currentPage: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Navbar: React.FC<NavbarProps> = ({ onNavigate, currentPage }) => {
|
|
||||||
const { user, logout } = useAuth();
|
|
||||||
const [isScrolled, setIsScrolled] = useState(false);
|
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleScroll = () => {
|
|
||||||
setIsScrolled(window.scrollY > 20);
|
|
||||||
};
|
|
||||||
window.addEventListener('scroll', handleScroll);
|
|
||||||
return () => window.removeEventListener('scroll', handleScroll);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getLinks = () => {
|
|
||||||
if (!user) return [];
|
|
||||||
|
|
||||||
switch (user.role) {
|
|
||||||
case UserRole.SUPERADMIN:
|
|
||||||
case UserRole.BUSINESS_OWNER:
|
|
||||||
return [
|
|
||||||
{ name: 'Gestão de Eventos', path: 'dashboard' },
|
|
||||||
{ name: 'Equipe & Fotógrafos', path: 'team' },
|
|
||||||
{ name: 'Financeiro', path: 'finance' },
|
|
||||||
{ name: 'Configurações', path: 'settings' }
|
|
||||||
];
|
|
||||||
case UserRole.EVENT_OWNER:
|
|
||||||
return [
|
|
||||||
{ name: 'Meus Eventos', path: 'dashboard' },
|
|
||||||
{ name: 'Solicitar Evento', path: 'request-event' },
|
|
||||||
{ name: 'Álbuns Entregues', path: 'albums' }
|
|
||||||
];
|
|
||||||
case UserRole.PHOTOGRAPHER:
|
|
||||||
return [
|
|
||||||
{ name: 'Eventos Designados', path: 'dashboard' },
|
|
||||||
{ name: 'Meus Uploads', path: 'uploads' },
|
|
||||||
{ name: 'Agenda', path: 'calendar' }
|
|
||||||
];
|
|
||||||
default:
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRoleLabel = () => {
|
|
||||||
if (!user) return "";
|
|
||||||
if (user.role === UserRole.BUSINESS_OWNER) return "Empresa";
|
|
||||||
if (user.role === UserRole.EVENT_OWNER) return "Cliente";
|
|
||||||
if (user.role === UserRole.PHOTOGRAPHER) return "Fotógrafo";
|
|
||||||
if (user.role === UserRole.SUPERADMIN) return "Super Admin";
|
|
||||||
return "";
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav
|
|
||||||
className={`fixed w-full z-50 transition-all duration-300 border-b ${
|
|
||||||
isScrolled ? 'bg-white/95 backdrop-blur-md shadow-sm border-gray-100 py-2' : 'bg-white border-transparent py-4'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div className="flex justify-between items-center h-12">
|
|
||||||
|
|
||||||
{/* Logo */}
|
|
||||||
<div
|
|
||||||
className="flex-shrink-0 flex items-center cursor-pointer"
|
|
||||||
onClick={() => onNavigate('home')}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src="https://photum.com.br/wp-content/uploads/2019/09/logo-photum.png"
|
|
||||||
alt="Photum Formaturas"
|
|
||||||
className="h-10 md:h-12 w-auto object-contain transition-all hover:opacity-90"
|
|
||||||
onError={(e) => {
|
|
||||||
e.currentTarget.onerror = null;
|
|
||||||
e.currentTarget.style.display = 'none';
|
|
||||||
e.currentTarget.nextElementSibling?.classList.remove('hidden');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Fallback Text Logo if image fails */}
|
|
||||||
<span className="hidden font-sans font-bold text-xl tracking-tight text-brand-black ml-2">
|
|
||||||
PHOTUM<span className="font-light text-brand-gold">FORMATURAS</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop Navigation */}
|
|
||||||
{user && (
|
|
||||||
<div className="hidden md:flex items-center space-x-6">
|
|
||||||
{getLinks().map((link) => (
|
|
||||||
<button
|
|
||||||
key={link.path}
|
|
||||||
onClick={() => onNavigate(link.path)}
|
|
||||||
className={`text-sm font-medium tracking-wide uppercase hover:text-brand-gold transition-colors pb-1 ${
|
|
||||||
currentPage === link.path ? 'text-brand-gold border-b-2 border-brand-gold' : 'text-gray-600 border-b-2 border-transparent'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{link.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Right Side Actions */}
|
|
||||||
<div className="hidden md:flex items-center space-x-4">
|
|
||||||
{user ? (
|
|
||||||
<div className="flex items-center space-x-4 pl-4 border-l border-gray-200">
|
|
||||||
<div className="flex flex-col items-end mr-2">
|
|
||||||
<span className="text-sm font-bold text-brand-black leading-tight">{user.name}</span>
|
|
||||||
<span className="text-[10px] uppercase tracking-wider text-brand-gold leading-tight">{getRoleLabel()}</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-9 w-9 rounded-full bg-gray-100 overflow-hidden border border-gray-200 ring-2 ring-transparent hover:ring-brand-gold transition-all">
|
|
||||||
<img src={user.avatar} alt="Avatar" className="w-full h-full object-cover" />
|
|
||||||
</div>
|
|
||||||
<button onClick={logout} className="text-gray-400 hover:text-red-500 transition-colors p-1" title="Sair">
|
|
||||||
<LogOut size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button onClick={() => onNavigate('login')} size="sm">
|
|
||||||
Login
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Button */}
|
|
||||||
<div className="md:hidden flex items-center">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
|
||||||
className="text-brand-black hover:text-brand-gold p-2"
|
|
||||||
>
|
|
||||||
{isMobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Menu */}
|
|
||||||
{isMobileMenuOpen && (
|
|
||||||
<div className="md:hidden absolute top-full left-0 w-full bg-white border-b border-gray-100 shadow-lg fade-in">
|
|
||||||
<div className="px-4 py-4 space-y-3">
|
|
||||||
{user && getLinks().map((link) => (
|
|
||||||
<button
|
|
||||||
key={link.path}
|
|
||||||
onClick={() => {
|
|
||||||
onNavigate(link.path);
|
|
||||||
setIsMobileMenuOpen(false);
|
|
||||||
}}
|
|
||||||
className="block w-full text-left text-base font-medium text-gray-700 hover:text-brand-gold py-2 border-b border-gray-50"
|
|
||||||
>
|
|
||||||
{link.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<div className="pt-4">
|
|
||||||
{user ? (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<img src={user.avatar} className="w-8 h-8 rounded-full mr-2"/>
|
|
||||||
<div>
|
|
||||||
<span className="font-bold text-sm block">{user.name}</span>
|
|
||||||
<span className="text-xs text-brand-gold">{getRoleLabel()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" size="sm" onClick={logout}>Sair</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button className="w-full" onClick={() => onNavigate('login')}>
|
|
||||||
Acessar Painel
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
|
|
||||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
|
||||||
import { EventData, EventStatus, EventType, Attachment } from '../types';
|
|
||||||
|
|
||||||
// Initial Mock Data
|
|
||||||
const INITIAL_EVENTS: EventData[] = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
name: 'Casamento Juliana & Marcos',
|
|
||||||
date: '2024-10-15',
|
|
||||||
time: '16:00',
|
|
||||||
type: EventType.WEDDING,
|
|
||||||
status: EventStatus.CONFIRMED,
|
|
||||||
address: {
|
|
||||||
street: 'Av. das Hortênsias',
|
|
||||||
number: '1200',
|
|
||||||
city: 'Gramado',
|
|
||||||
state: 'RS',
|
|
||||||
zip: '95670-000'
|
|
||||||
},
|
|
||||||
briefing: 'Cerimônia ao pôr do sol. Foco em fotos espontâneas dos noivos e pais.',
|
|
||||||
coverImage: 'https://picsum.photos/id/1059/800/400',
|
|
||||||
contacts: [{ id: 'c1', name: 'Cerimonial Silva', role: 'Cerimonialista', phone: '9999-9999', email: 'c@teste.com'}],
|
|
||||||
checklist: [],
|
|
||||||
attachments: [
|
|
||||||
{ name: 'Ensaio 1', size: '2mb', type: 'image/jpeg', url: 'https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=400&q=80' },
|
|
||||||
{ name: 'Ensaio 2', size: '2mb', type: 'image/jpeg', url: 'https://images.unsplash.com/photo-1511285560982-1351cdeb9821?auto=format&fit=crop&w=400&q=80' }
|
|
||||||
],
|
|
||||||
ownerId: 'client-1',
|
|
||||||
photographerIds: ['photographer-1']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
name: 'Conferência Tech Innovators',
|
|
||||||
date: '2024-11-05',
|
|
||||||
time: '08:00',
|
|
||||||
type: EventType.CORPORATE,
|
|
||||||
status: EventStatus.PENDING_APPROVAL,
|
|
||||||
address: {
|
|
||||||
street: 'Rua Olimpíadas',
|
|
||||||
number: '205',
|
|
||||||
city: 'São Paulo',
|
|
||||||
state: 'SP',
|
|
||||||
zip: '04551-000'
|
|
||||||
},
|
|
||||||
briefing: 'Cobrir palestras principais e networking no coffee break.',
|
|
||||||
coverImage: 'https://picsum.photos/id/3/800/400',
|
|
||||||
contacts: [],
|
|
||||||
checklist: [],
|
|
||||||
attachments: [],
|
|
||||||
ownerId: 'client-2', // Other client
|
|
||||||
photographerIds: []
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
interface DataContextType {
|
|
||||||
events: EventData[];
|
|
||||||
addEvent: (event: EventData) => void;
|
|
||||||
updateEventStatus: (id: string, status: EventStatus) => void;
|
|
||||||
assignPhotographer: (eventId: string, photographerId: string) => void;
|
|
||||||
getEventsByRole: (userId: string, role: string) => EventData[];
|
|
||||||
addAttachment: (eventId: string, attachment: Attachment) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DataContext = createContext<DataContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
export const DataProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
|
||||||
const [events, setEvents] = useState<EventData[]>(INITIAL_EVENTS);
|
|
||||||
|
|
||||||
const addEvent = (event: EventData) => {
|
|
||||||
setEvents(prev => [event, ...prev]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateEventStatus = (id: string, status: EventStatus) => {
|
|
||||||
setEvents(prev => prev.map(e => e.id === id ? { ...e, status } : e));
|
|
||||||
};
|
|
||||||
|
|
||||||
const assignPhotographer = (eventId: string, photographerId: string) => {
|
|
||||||
setEvents(prev => prev.map(e => {
|
|
||||||
if (e.id === eventId) {
|
|
||||||
const current = e.photographerIds || [];
|
|
||||||
if (!current.includes(photographerId)) {
|
|
||||||
return { ...e, photographerIds: [...current, photographerId] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return e;
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const getEventsByRole = (userId: string, role: string) => {
|
|
||||||
if (role === 'SUPERADMIN' || role === 'BUSINESS_OWNER') {
|
|
||||||
return events;
|
|
||||||
}
|
|
||||||
if (role === 'EVENT_OWNER') {
|
|
||||||
return events.filter(e => e.ownerId === userId);
|
|
||||||
}
|
|
||||||
if (role === 'PHOTOGRAPHER') {
|
|
||||||
return events.filter(e => e.photographerIds.includes(userId));
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
};
|
|
||||||
|
|
||||||
const addAttachment = (eventId: string, attachment: Attachment) => {
|
|
||||||
setEvents(prev => prev.map(e => {
|
|
||||||
if (e.id === eventId) {
|
|
||||||
return { ...e, attachments: [...e.attachments, attachment] };
|
|
||||||
}
|
|
||||||
return e;
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataContext.Provider value={{ events, addEvent, updateEventStatus, assignPhotographer, getEventsByRole, addAttachment }}>
|
|
||||||
{children}
|
|
||||||
</DataContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useData = () => {
|
|
||||||
const context = useContext(DataContext);
|
|
||||||
if (!context) throw new Error('useData must be used within a DataProvider');
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
12
frontend/.env.example
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# Configuração da API do Mapbox
|
||||||
|
# Obtenha sua chave gratuita em: https://account.mapbox.com/
|
||||||
|
|
||||||
|
# Para usar:
|
||||||
|
# 1. Copie este arquivo para .env.local
|
||||||
|
# 2. Substitua YOUR_MAPBOX_TOKEN_HERE pela sua chave real
|
||||||
|
# 3. Atualize o arquivo services/mapboxService.ts com sua chave
|
||||||
|
|
||||||
|
VITE_MAPBOX_TOKEN=YOUR_MAPBOX_TOKEN_HERE
|
||||||
|
|
||||||
|
# Nota: A chave já está configurada diretamente no código para demonstração
|
||||||
|
# Em produção, use variáveis de ambiente como acima
|
||||||
0
.gitignore → frontend/.gitignore
vendored
266
frontend/App.tsx
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Navbar } from "./components/Navbar";
|
||||||
|
import { Home } from "./pages/Home";
|
||||||
|
import { Dashboard } from "./pages/Dashboard";
|
||||||
|
import { Login } from "./pages/Login";
|
||||||
|
import { Register } from "./pages/Register";
|
||||||
|
import { CalendarPage } from "./pages/Calendar";
|
||||||
|
import { TeamPage } from "./pages/Team";
|
||||||
|
import { FinancePage } from "./pages/Finance";
|
||||||
|
import { SettingsPage } from "./pages/Settings";
|
||||||
|
import { InspirationPage } from "./pages/Inspiration";
|
||||||
|
import { PrivacyPolicy } from "./pages/PrivacyPolicy";
|
||||||
|
import { TermsOfUse } from "./pages/TermsOfUse";
|
||||||
|
import { LGPD } from "./pages/LGPD";
|
||||||
|
import { AuthProvider, useAuth } from "./contexts/AuthContext";
|
||||||
|
import { DataProvider } from "./contexts/DataContext";
|
||||||
|
import { Construction } from "lucide-react";
|
||||||
|
|
||||||
|
const AppContent: React.FC = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [currentPage, setCurrentPage] = useState("home");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && currentPage === "login") {
|
||||||
|
setCurrentPage("dashboard");
|
||||||
|
}
|
||||||
|
}, [user, currentPage]);
|
||||||
|
|
||||||
|
const renderPage = () => {
|
||||||
|
if (currentPage === "home")
|
||||||
|
return (
|
||||||
|
<Home onEnter={() => setCurrentPage(user ? "dashboard" : "login")} />
|
||||||
|
);
|
||||||
|
if (currentPage === "login")
|
||||||
|
return user ? <Dashboard /> : <Login onNavigate={setCurrentPage} />;
|
||||||
|
if (currentPage === "register")
|
||||||
|
return user ? <Dashboard /> : <Register onNavigate={setCurrentPage} />;
|
||||||
|
if (currentPage === "privacy")
|
||||||
|
return <PrivacyPolicy onNavigate={setCurrentPage} />;
|
||||||
|
if (currentPage === "terms")
|
||||||
|
return <TermsOfUse onNavigate={setCurrentPage} />;
|
||||||
|
if (currentPage === "lgpd") return <LGPD onNavigate={setCurrentPage} />;
|
||||||
|
|
||||||
|
if (!user) return <Login onNavigate={setCurrentPage} />;
|
||||||
|
|
||||||
|
switch (currentPage) {
|
||||||
|
case "dashboard":
|
||||||
|
case "events":
|
||||||
|
return <Dashboard initialView="list" />;
|
||||||
|
|
||||||
|
case "request-event":
|
||||||
|
return <Dashboard initialView="create" />;
|
||||||
|
|
||||||
|
case "inspiration":
|
||||||
|
return <InspirationPage />;
|
||||||
|
|
||||||
|
case "calendar":
|
||||||
|
return <CalendarPage />;
|
||||||
|
|
||||||
|
case "team":
|
||||||
|
return <TeamPage />;
|
||||||
|
|
||||||
|
case "finance":
|
||||||
|
return <FinancePage />;
|
||||||
|
|
||||||
|
case "settings":
|
||||||
|
return <SettingsPage />;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return <Dashboard initialView="list" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white">
|
||||||
|
<Navbar onNavigate={setCurrentPage} currentPage={currentPage} />
|
||||||
|
<main>{renderPage()}</main>
|
||||||
|
|
||||||
|
{currentPage === "home" && (
|
||||||
|
<footer className="bg-gradient-to-br from-brand-purple to-brand-purple/90 text-brand-black py-16 md:py-20">
|
||||||
|
<div className="w-full max-w-[1600px] mx-auto px-6 sm:px-8 md:px-12 lg:px-16 xl:px-20">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8 sm:gap-10 md:gap-12 lg:gap-16 xl:gap-20 mb-12 md:mb-16">
|
||||||
|
{/* Logo e Descrição */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<img
|
||||||
|
src="/logo.png"
|
||||||
|
alt="Photum Formaturas"
|
||||||
|
className="h-40 sm:h-34 md:h-28 lg:h-42 mb-4 md:mb-6"
|
||||||
|
/>
|
||||||
|
<p className="text-brand-black/80 text-sm sm:text-base md:text-lg leading-relaxed">
|
||||||
|
Eternizando momentos únicos com excelência e profissionalismo
|
||||||
|
desde 2020.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Serviços */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold text-brand-black mb-4 md:mb-6 uppercase tracking-wider text-sm sm:text-base md:text-lg">
|
||||||
|
Serviços
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-2 md:space-y-3 text-brand-black/70 text-sm sm:text-base md:text-lg">
|
||||||
|
<li>Fotografia de Formatura</li>
|
||||||
|
<li>Baile de Gala</li>
|
||||||
|
<li>Cerimônia de Colação</li>
|
||||||
|
<li>Ensaios de Turma</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Links Úteis */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold text-brand-black mb-4 md:mb-6 uppercase tracking-wider text-sm sm:text-base md:text-lg">
|
||||||
|
Links Úteis
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-2 md:space-y-3 text-brand-black/70 text-sm sm:text-base md:text-lg">
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
onClick={() => setCurrentPage("login")}
|
||||||
|
className="hover:text-brand-black transition-colors"
|
||||||
|
>
|
||||||
|
Área do Cliente
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
onClick={() => setCurrentPage("register")}
|
||||||
|
className="hover:text-brand-black transition-colors"
|
||||||
|
>
|
||||||
|
Cadastre sua Formatura
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contato */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold text-brand-black mb-4 md:mb-6 uppercase tracking-wider text-sm sm:text-base md:text-lg">
|
||||||
|
Contato
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-3 md:space-y-4 text-brand-black/70 text-sm sm:text-base md:text-lg">
|
||||||
|
<li className="flex items-center gap-2 md:gap-3">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 md:w-6 md:h-6 flex-shrink-0"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z" />
|
||||||
|
</svg>
|
||||||
|
<a
|
||||||
|
href="mailto:contato@photum.com.br"
|
||||||
|
className="hover:text-brand-black transition-colors break-all"
|
||||||
|
>
|
||||||
|
contato@photum.com.br
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2 md:gap-3">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 md:w-6 md:h-6 flex-shrink-0 mt-0.5"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path d="M20 15.5c-1.25 0-2.45-.2-3.57-.57-.35-.11-.74-.03-1.02.24l-2.2 2.2c-2.83-1.44-5.15-3.75-6.59-6.59l2.2-2.21c.28-.26.36-.65.25-1C8.7 6.45 8.5 5.25 8.5 4c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1 0 9.39 7.61 17 17 17 .55 0 1-.45 1-1v-3.5c0-.55-.45-1-1-1zM19 12h2c0-4.97-4.03-9-9-9v2c3.87 0 7 3.13 7 7zm-4 0h2c0-2.76-2.24-5-5-5v2c1.66 0 3 1.34 3 3z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p>(19) 3405 5024</p>
|
||||||
|
<p>(19) 3621 4621</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2 md:gap-3">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 md:w-6 md:h-6 flex-shrink-0"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z" />
|
||||||
|
</svg>
|
||||||
|
<span>Americana, SP</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex gap-4 md:gap-5 mt-4 md:mt-6">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="hover:text-brand-black transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-7 h-7 md:w-8 md:h-8"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="hover:text-brand-black transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-7 h-7 md:w-8 md:h-8"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path d="M22.675 0h-21.35c-.732 0-1.325.593-1.325 1.325v21.351c0 .731.593 1.324 1.325 1.324h11.495v-9.294h-3.128v-3.622h3.128v-2.671c0-3.1 1.893-4.788 4.659-4.788 1.325 0 2.463.099 2.795.143v3.24l-1.918.001c-1.504 0-1.795.715-1.795 1.763v2.313h3.587l-.467 3.622h-3.12v9.293h6.116c.73 0 1.323-.593 1.323-1.325v-21.35c0-.732-.593-1.325-1.325-1.325z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="hover:text-brand-black transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-7 h-7 md:w-8 md:h-8"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Bar */}
|
||||||
|
<div className="border-t border-black/20 pt-6 md:pt-8 flex flex-col md:flex-row justify-between items-center text-xs sm:text-sm md:text-base text-brand-black/60 gap-4">
|
||||||
|
<p>© 2025 PhotumFormaturas. Todos os direitos reservados.</p>
|
||||||
|
<div className="flex flex-wrap justify-center gap-4 sm:gap-6 md:gap-8">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
onClick={() => setCurrentPage("privacy")}
|
||||||
|
className="hover:text-brand-black transition-colors"
|
||||||
|
>
|
||||||
|
Política de Privacidade
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
onClick={() => setCurrentPage("terms")}
|
||||||
|
className="hover:text-brand-black transition-colors"
|
||||||
|
>
|
||||||
|
Termos de Uso
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
onClick={() => setCurrentPage("lgpd")}
|
||||||
|
className="hover:text-brand-black transition-colors"
|
||||||
|
>
|
||||||
|
LGPD
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<DataProvider>
|
||||||
|
<AppContent />
|
||||||
|
</DataProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
79
frontend/MAPBOX_SETUP.md
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# 🗺️ Configuração do Mapbox
|
||||||
|
|
||||||
|
## Token do Mapbox Inválido
|
||||||
|
|
||||||
|
O sistema está configurado para usar a API do Mapbox, mas o token atual é inválido ou expirado.
|
||||||
|
|
||||||
|
## Como Obter um Token Válido (GRATUITO)
|
||||||
|
|
||||||
|
### 1. Crie uma conta no Mapbox
|
||||||
|
|
||||||
|
- Acesse: **https://account.mapbox.com/**
|
||||||
|
- Clique em **"Sign up"** (ou faça login se já tiver conta)
|
||||||
|
- É **100% gratuito** para até 50.000 requisições/mês
|
||||||
|
|
||||||
|
### 2. Acesse a página de Tokens
|
||||||
|
|
||||||
|
- Após fazer login, vá para: **https://account.mapbox.com/access-tokens/**
|
||||||
|
- Ou clique no menu em **"Access tokens"**
|
||||||
|
|
||||||
|
### 3. Crie um novo token
|
||||||
|
|
||||||
|
- Clique em **"Create a token"**
|
||||||
|
- Dê um nome (ex: "Photum Forms")
|
||||||
|
- Selecione os escopos necessários (deixe os padrões marcados)
|
||||||
|
- Clique em **"Create token"**
|
||||||
|
|
||||||
|
### 4. Copie o token
|
||||||
|
|
||||||
|
- O token será algo como: `pk.eyJ1IjoiZXhhbXBsZSIsImEiOiJja...`
|
||||||
|
- **COPIE O TOKEN COMPLETO**
|
||||||
|
|
||||||
|
### 5. Configure no Projeto
|
||||||
|
|
||||||
|
Abra o arquivo **`services/mapboxService.ts`** e substitua o token na linha 26:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const MAPBOX_TOKEN = "SEU_TOKEN_AQUI"; // Cole o token do Mapbox aqui
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Salve e recarregue
|
||||||
|
|
||||||
|
Após salvar o arquivo, o Vite recarregará automaticamente e o mapa funcionará!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verificação
|
||||||
|
|
||||||
|
Se tudo estiver correto, você verá:
|
||||||
|
|
||||||
|
- ✅ "Inicializando mapa Mapbox..." no console
|
||||||
|
- ✅ "Mapa criado com sucesso" no console
|
||||||
|
- ✅ Mapa interativo carregado na tela de criação de eventos
|
||||||
|
|
||||||
|
Se houver erro:
|
||||||
|
|
||||||
|
- ❌ Verifique se copiou o token completo (incluindo `pk.`)
|
||||||
|
- ❌ Verifique se não há espaços extras antes/depois do token
|
||||||
|
- ❌ Certifique-se de que o token não expirou
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recursos do Mapbox no Sistema
|
||||||
|
|
||||||
|
- 🔍 Busca de endereços com autocomplete
|
||||||
|
- 📍 Mapa interativo com pin arrastável
|
||||||
|
- 🌍 Geocoding e Reverse Geocoding
|
||||||
|
- 🗺️ Integração com Google Maps para compartilhamento
|
||||||
|
|
||||||
|
## Limites Gratuitos
|
||||||
|
|
||||||
|
O plano gratuito do Mapbox inclui:
|
||||||
|
|
||||||
|
- 50.000 requisições de geocoding/mês
|
||||||
|
- 50.000 carregamentos de mapa/mês
|
||||||
|
- Mais que suficiente para este projeto!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Precisa de ajuda?** Acesse a documentação: https://docs.mapbox.com/api/
|
||||||
397
frontend/README.md
Normal file
|
|
@ -0,0 +1,397 @@
|
||||||
|
# 📸 Photum Manager
|
||||||
|
|
||||||
|
Sistema de gestão completa para eventos fotográficos premium. Plataforma moderna para gerenciamento de eventos, fotógrafos, clientes e entregas, com foco em excelência e atendimento humanizado.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Visão Geral
|
||||||
|
|
||||||
|
O **Photum Manager** é uma aplicação web desenvolvida em React + TypeScript que centraliza todo o fluxo de trabalho de uma empresa de fotografia de eventos, desde a solicitação inicial do cliente até a entrega final do álbum.
|
||||||
|
|
||||||
|
### Principais Funcionalidades
|
||||||
|
|
||||||
|
- ✅ **Gestão Multi-perfil**: Suporte para 4 tipos de usuários (Superadmin, Empresa, Fotógrafo, Cliente)
|
||||||
|
- ✅ **Controle de Eventos**: CRUD completo de eventos com status workflow
|
||||||
|
- ✅ **Dashboard Personalizado**: Interface adaptada por perfil de usuário
|
||||||
|
- ✅ **Sistema de Aprovações**: Workflow de aprovação de eventos para clientes
|
||||||
|
- ✅ **Gestão de Equipe**: Atribuição de fotógrafos aos eventos
|
||||||
|
- ✅ **Upload de Fotos**: Sistema de anexos e galeria (em desenvolvimento)
|
||||||
|
- ✅ **Integração com IA**: Google GenAI para funcionalidades avançadas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Como Executar o Projeto
|
||||||
|
|
||||||
|
### Pré-requisitos
|
||||||
|
|
||||||
|
- **Node.js** (versão 16 ou superior)
|
||||||
|
- **npm** ou **yarn**
|
||||||
|
|
||||||
|
### Instalação
|
||||||
|
|
||||||
|
1. **Clone o repositório**:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/rede5/photum-frontend.git
|
||||||
|
cd photum-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Instale as dependências**:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Configure a API do Gemini** (opcional):
|
||||||
|
- Crie um arquivo `.env.local` na raiz do projeto
|
||||||
|
- Adicione sua chave de API:
|
||||||
|
```
|
||||||
|
GEMINI_API_KEY=sua_chave_aqui
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Execute o projeto em modo desenvolvimento**:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Acesse no navegador**:
|
||||||
|
```
|
||||||
|
http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build para Produção
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗺️ Rotas e Navegação
|
||||||
|
|
||||||
|
O sistema utiliza um **roteamento customizado baseado em estados** (sem React Router). As rotas disponíveis são:
|
||||||
|
|
||||||
|
| Rota | Descrição | Acesso |
|
||||||
|
|------|-----------|--------|
|
||||||
|
| `/` (`home`) | Landing page institucional | Público |
|
||||||
|
| `/login` | Tela de autenticação | Público |
|
||||||
|
| `/dashboard` | Dashboard principal (Lista de eventos) | Autenticado |
|
||||||
|
| `/events` | Mesma view de dashboard | Autenticado |
|
||||||
|
| `/request-event` | Formulário de criação de evento | Autenticado |
|
||||||
|
| `/uploads` | Gerenciamento de uploads de fotos | Autenticado |
|
||||||
|
| `/team` | Gestão de equipe e fotógrafos | 🚧 Em desenvolvimento |
|
||||||
|
| `/finance` | Módulo financeiro | 🚧 Em desenvolvimento |
|
||||||
|
| `/calendar` | Agenda de eventos | 🚧 Em desenvolvimento |
|
||||||
|
| `/settings` | Configurações | 🚧 Em desenvolvimento |
|
||||||
|
| `/albums` | Álbuns de fotos | 🚧 Em desenvolvimento |
|
||||||
|
|
||||||
|
### Navegação no Sistema
|
||||||
|
|
||||||
|
A navegação acontece através do componente `<Navbar>` que chama a função `onNavigate(pageName)` passando o nome da rota desejada. Rotas protegidas redirecionam automaticamente para `/login` se o usuário não estiver autenticado.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Tela de Login e Usuários de Demonstração
|
||||||
|
|
||||||
|
### Como Acessar
|
||||||
|
|
||||||
|
1. Na página inicial, clique em **"Área do Cliente"**
|
||||||
|
2. Você será redirecionado para a **tela de login** (`/login`)
|
||||||
|
|
||||||
|
### Interface de Login
|
||||||
|
|
||||||
|
A tela de login apresenta:
|
||||||
|
- **Layout dividido**:
|
||||||
|
- Lado esquerdo com imagem institucional
|
||||||
|
- Lado direito com formulário de login
|
||||||
|
- **Campos**:
|
||||||
|
- E-mail corporativo ou pessoal
|
||||||
|
- Senha (desabilitada no modo demo)
|
||||||
|
- **Botão**: "Entrar no Sistema"
|
||||||
|
|
||||||
|
### Usuários de Demonstração
|
||||||
|
|
||||||
|
Não é necessário senha. Basta clicar em um dos cartões de usuário demo ou digitar o e-mail:
|
||||||
|
|
||||||
|
| Perfil | Nome | E-mail | Descrição |
|
||||||
|
|--------|------|--------|-----------|
|
||||||
|
| **Superadmin** | Dev Admin | `admin@photum.com` | Acesso total ao sistema |
|
||||||
|
| **Empresa** | Carlos CEO | `empresa@photum.com` | Dono da empresa de fotografia |
|
||||||
|
| **Fotógrafo** | Ana Lente | `foto@photum.com` | Profissional fotógrafo |
|
||||||
|
| **Cliente** | Juliana Noiva | `cliente@photum.com` | Cliente final (dono do evento) |
|
||||||
|
|
||||||
|
### Fluxo de Login
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Usuário clica em "Área do Cliente" na Home
|
||||||
|
↓
|
||||||
|
2. Sistema redireciona para /login
|
||||||
|
↓
|
||||||
|
3. Usuário seleciona um perfil demo OU digita e-mail manualmente
|
||||||
|
↓
|
||||||
|
4. Sistema valida o e-mail com mock de usuários
|
||||||
|
↓
|
||||||
|
5. Se válido: redireciona para /dashboard
|
||||||
|
Se inválido: exibe mensagem de erro
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exemplo de Código (Login)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Login simplificado (sem senha)
|
||||||
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const success = await login(email); // Busca no mock de usuários
|
||||||
|
if (!success) {
|
||||||
|
setError('Usuário não encontrado');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 👥 Perfis de Usuário e Permissões
|
||||||
|
|
||||||
|
### 1. Superadmin (`SUPERADMIN`)
|
||||||
|
- **Acesso**: Total ao sistema
|
||||||
|
- **Funcionalidades**:
|
||||||
|
- Visualiza todos os eventos
|
||||||
|
- Gerencia todos os usuários
|
||||||
|
- Acesso a configurações avançadas
|
||||||
|
|
||||||
|
### 2. Dono da Empresa (`BUSINESS_OWNER`)
|
||||||
|
- **Acesso**: Administrativo
|
||||||
|
- **Funcionalidades**:
|
||||||
|
- Cria e edita eventos
|
||||||
|
- Atribui fotógrafos
|
||||||
|
- Aprova solicitações de clientes
|
||||||
|
- Gerencia equipe
|
||||||
|
|
||||||
|
### 3. Fotógrafo (`PHOTOGRAPHER`)
|
||||||
|
- **Acesso**: Operacional
|
||||||
|
- **Funcionalidades**:
|
||||||
|
- Visualiza eventos atribuídos
|
||||||
|
- Atualiza status de eventos
|
||||||
|
- Faz upload de fotos
|
||||||
|
- Acessa detalhes de contatos
|
||||||
|
|
||||||
|
### 4. Cliente (`EVENT_OWNER`)
|
||||||
|
- **Acesso**: Restrito aos próprios eventos
|
||||||
|
- **Funcionalidades**:
|
||||||
|
- Solicita novos eventos
|
||||||
|
- Acompanha status do seu evento
|
||||||
|
- Visualiza fotos do evento
|
||||||
|
- Eventos criados entram em status "Aguardando Aprovação"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Status de Eventos
|
||||||
|
|
||||||
|
Os eventos seguem um workflow com os seguintes status:
|
||||||
|
|
||||||
|
| Status | Descrição | Cor |
|
||||||
|
|--------|-----------|-----|
|
||||||
|
| `Aguardando Aprovação` | Cliente criou, aguarda aprovação da empresa | Cinza |
|
||||||
|
| `Em Planejamento` | Aprovado, em fase de planejamento | Azul |
|
||||||
|
| `Confirmado` | Evento confirmado e agendado | Verde |
|
||||||
|
| `Em Execução` | Evento acontecendo | Roxo |
|
||||||
|
| `Entregue` | Fotos entregues ao cliente | Verde escuro |
|
||||||
|
| `Arquivado` | Evento finalizado e arquivado | Cinza escuro |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Tecnologias Utilizadas
|
||||||
|
|
||||||
|
### Core
|
||||||
|
- **React 19.2.0** - Biblioteca JavaScript para UI
|
||||||
|
- **TypeScript 5.8.2** - Superset tipado do JavaScript
|
||||||
|
- **Vite 6.2.0** - Build tool e dev server ultra-rápido
|
||||||
|
|
||||||
|
### UI/UX
|
||||||
|
- **Lucide React** - Ícones modernos e customizáveis
|
||||||
|
- **TailwindCSS** - Framework CSS utility-first (configurado via classes)
|
||||||
|
|
||||||
|
### IA e APIs
|
||||||
|
- **@google/genai 1.30.0** - Integração com Google Gemini AI
|
||||||
|
|
||||||
|
### Build Tools
|
||||||
|
- **@vitejs/plugin-react 5.0.0** - Plugin oficial do Vite para React
|
||||||
|
- **@types/node 22.14.0** - Tipos TypeScript para Node.js
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Estrutura do Projeto
|
||||||
|
|
||||||
|
```
|
||||||
|
photum-forms/
|
||||||
|
├── components/ # Componentes reutilizáveis
|
||||||
|
│ ├── Button.tsx # Botão customizado
|
||||||
|
│ ├── EventCard.tsx # Card de evento
|
||||||
|
│ ├── EventForm.tsx # Formulário de evento
|
||||||
|
│ ├── Input.tsx # Input customizado
|
||||||
|
│ └── Navbar.tsx # Barra de navegação
|
||||||
|
├── contexts/ # Context API
|
||||||
|
│ ├── AuthContext.tsx # Autenticação e usuários
|
||||||
|
│ └── DataContext.tsx # Gerenciamento de eventos
|
||||||
|
├── pages/ # Páginas principais
|
||||||
|
│ ├── Dashboard.tsx # Dashboard principal
|
||||||
|
│ ├── Home.tsx # Landing page
|
||||||
|
│ └── Login.tsx # Tela de login
|
||||||
|
├── services/ # Serviços e APIs
|
||||||
|
│ ├── genaiService.ts # Integração Google GenAI
|
||||||
|
│ └── mockGeoService.ts # Mock de geolocalização
|
||||||
|
├── App.tsx # Componente raiz + roteamento
|
||||||
|
├── constants.ts # Constantes globais
|
||||||
|
├── types.ts # Tipos TypeScript
|
||||||
|
├── index.tsx # Entry point
|
||||||
|
├── package.json # Dependências
|
||||||
|
├── tsconfig.json # Config TypeScript
|
||||||
|
└── vite.config.ts # Config Vite
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Temas e Design System
|
||||||
|
|
||||||
|
### Cores da Marca
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Cores principais */
|
||||||
|
--brand-black: #000000
|
||||||
|
--brand-gold: #c5a059
|
||||||
|
--brand-white: #ffffff
|
||||||
|
|
||||||
|
/* Status Colors (definidas em constants.ts) */
|
||||||
|
Aguardando: #6b7280 (gray)
|
||||||
|
Planejamento: #3b82f6 (blue)
|
||||||
|
Confirmado: #10b981 (green)
|
||||||
|
Em Execução: #8b5cf6 (purple)
|
||||||
|
Entregue: #059669 (emerald)
|
||||||
|
Arquivado: #4b5563 (gray-dark)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tipografia
|
||||||
|
|
||||||
|
- **Títulos**: Font Serif (elegante e sofisticada)
|
||||||
|
- **Corpo**: Font Sans (moderna e legível)
|
||||||
|
- **Tamanhos**: Sistema responsivo com classes Tailwind
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Workflows do Sistema
|
||||||
|
|
||||||
|
### Workflow de Criação de Evento (Cliente)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Cliente faz login
|
||||||
|
↓
|
||||||
|
2. Acessa "Solicitar Evento"
|
||||||
|
↓
|
||||||
|
3. Preenche formulário (nome, tipo, data, endereço, contatos)
|
||||||
|
↓
|
||||||
|
4. Evento criado com status "Aguardando Aprovação"
|
||||||
|
↓
|
||||||
|
5. Empresa visualiza solicitação
|
||||||
|
↓
|
||||||
|
6. Empresa aprova → Status muda para "Confirmado"
|
||||||
|
↓
|
||||||
|
7. Empresa atribui fotógrafo
|
||||||
|
↓
|
||||||
|
8. Fotógrafo acessa evento e realiza trabalho
|
||||||
|
↓
|
||||||
|
9. Status atualizado conforme workflow
|
||||||
|
↓
|
||||||
|
10. Cliente visualiza fotos e álbum final
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workflow de Gestão (Empresa)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Empresa cria evento diretamente (status: "Em Planejamento")
|
||||||
|
↓
|
||||||
|
2. Adiciona informações detalhadas
|
||||||
|
↓
|
||||||
|
3. Atribui fotógrafos à equipe
|
||||||
|
↓
|
||||||
|
4. Confirma evento (status: "Confirmado")
|
||||||
|
↓
|
||||||
|
5. Acompanha execução
|
||||||
|
↓
|
||||||
|
6. Marca como entregue após conclusão
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Dados de Teste (Mock)
|
||||||
|
|
||||||
|
O sistema utiliza dados mockados para demonstração:
|
||||||
|
|
||||||
|
### Usuários Mock
|
||||||
|
- 4 usuários pré-cadastrados (ver seção de Login)
|
||||||
|
|
||||||
|
### Eventos Mock
|
||||||
|
- Eventos de exemplo criados no `DataContext.tsx`
|
||||||
|
- Incluem diferentes tipos: Casamentos, Corporativos, Aniversários
|
||||||
|
- Variados status para demonstrar o workflow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚧 Funcionalidades em Desenvolvimento
|
||||||
|
|
||||||
|
As seguintes rotas exibem tela de "Em construção":
|
||||||
|
- `/team` - Gestão de Equipe e Fotógrafos
|
||||||
|
- `/finance` - Módulo Financeiro
|
||||||
|
- `/calendar` - Agenda Completa
|
||||||
|
- `/settings` - Configurações do Sistema
|
||||||
|
- `/albums` - Galeria de Álbuns
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Scripts Disponíveis
|
||||||
|
|
||||||
|
| Comando | Descrição |
|
||||||
|
|---------|-----------|
|
||||||
|
| `npm run dev` | Inicia servidor de desenvolvimento (Vite) |
|
||||||
|
| `npm run build` | Gera build de produção otimizado |
|
||||||
|
| `npm run preview` | Preview do build de produção |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤝 Contribuindo
|
||||||
|
|
||||||
|
Este é um projeto em desenvolvimento ativo. Contribuições são bem-vindas!
|
||||||
|
|
||||||
|
### Branch Strategy
|
||||||
|
- **main**: Produção
|
||||||
|
- **dev**: Desenvolvimento ativo (branch atual)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 Licença
|
||||||
|
|
||||||
|
Projeto privado - Todos os direitos reservados © 2024 PhotumFormaturas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 Suporte
|
||||||
|
|
||||||
|
Para dúvidas ou suporte:
|
||||||
|
- **Repositório**: [github.com/rede5/photum-frontend](https://github.com/rede5/photum-frontend)
|
||||||
|
- **Issues**: Abra uma issue no GitHub
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Roadmap Futuro
|
||||||
|
|
||||||
|
- [ ] Implementar módulo de Agenda/Calendário
|
||||||
|
- [ ] Sistema completo de Upload de Fotos
|
||||||
|
- [ ] Módulo Financeiro (Pagamentos e Orçamentos)
|
||||||
|
- [ ] Gestão de Equipe completa
|
||||||
|
- [ ] Sistema de Notificações em tempo real
|
||||||
|
- [ ] App Mobile (React Native)
|
||||||
|
- [ ] Integração com APIs de pagamento
|
||||||
|
- [ ] Assinatura digital de contratos
|
||||||
|
- [ ] Exportação de relatórios PDF
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Desenvolvido com ❤️ para eternizar momentos únicos** ✨📸
|
||||||
|
|
@ -2,23 +2,23 @@ import React from 'react';
|
||||||
|
|
||||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
|
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
|
||||||
size?: 'sm' | 'md' | 'lg';
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Button: React.FC<ButtonProps> = ({
|
export const Button: React.FC<ButtonProps> = ({
|
||||||
children,
|
children,
|
||||||
variant = 'primary',
|
variant = 'primary',
|
||||||
size = 'md',
|
size = 'md',
|
||||||
isLoading,
|
isLoading,
|
||||||
className = '',
|
className = '',
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const baseStyles = "inline-flex items-center justify-center font-medium transition-all duration-300 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed";
|
const baseStyles = "inline-flex items-center justify-center font-medium transition-all duration-300 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed";
|
||||||
|
|
||||||
const variants = {
|
const variants = {
|
||||||
primary: "bg-brand-black text-white hover:bg-gray-800 focus:ring-brand-black",
|
primary: "bg-brand-black text-white hover:bg-gray-800 focus:ring-brand-black",
|
||||||
secondary: "bg-brand-gold text-white hover:bg-amber-600 focus:ring-brand-gold",
|
secondary: "bg-[#B9CF32] text-white hover:bg-[#a5bd2e] focus:ring-[#B9CF32]",
|
||||||
outline: "border border-brand-black text-brand-black hover:bg-gray-50 focus:ring-brand-black",
|
outline: "border border-brand-black text-brand-black hover:bg-gray-50 focus:ring-brand-black",
|
||||||
ghost: "text-brand-black hover:bg-gray-100 hover:text-gray-900"
|
ghost: "text-brand-black hover:bg-gray-100 hover:text-gray-900"
|
||||||
};
|
};
|
||||||
|
|
@ -26,11 +26,12 @@ export const Button: React.FC<ButtonProps> = ({
|
||||||
const sizes = {
|
const sizes = {
|
||||||
sm: "text-xs px-3 py-1.5 rounded-sm",
|
sm: "text-xs px-3 py-1.5 rounded-sm",
|
||||||
md: "text-sm px-5 py-2.5 rounded-sm",
|
md: "text-sm px-5 py-2.5 rounded-sm",
|
||||||
lg: "text-base px-8 py-3 rounded-sm"
|
lg: "text-base px-8 py-3 rounded-sm",
|
||||||
|
xl: "text-lg px-10 py-4 rounded-md font-semibold"
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
|
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||||
disabled={isLoading || props.disabled}
|
disabled={isLoading || props.disabled}
|
||||||
{...props}
|
{...props}
|
||||||
825
frontend/components/EventForm.tsx
Normal file
|
|
@ -0,0 +1,825 @@
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { EventType, EventStatus, Address } from "../types";
|
||||||
|
import { Input, Select } from "./Input";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
import {
|
||||||
|
MapPin,
|
||||||
|
Upload,
|
||||||
|
Plus,
|
||||||
|
X,
|
||||||
|
Check,
|
||||||
|
FileText,
|
||||||
|
ExternalLink,
|
||||||
|
Search,
|
||||||
|
CheckCircle,
|
||||||
|
Building2,
|
||||||
|
AlertCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
searchMapboxLocation,
|
||||||
|
MapboxResult,
|
||||||
|
reverseGeocode,
|
||||||
|
} from "../services/mapboxService";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import { useData } from "../contexts/DataContext";
|
||||||
|
import { UserRole } from "../types";
|
||||||
|
import { InstitutionForm } from "./InstitutionForm";
|
||||||
|
import { MapboxMap } from "./MapboxMap";
|
||||||
|
|
||||||
|
interface EventFormProps {
|
||||||
|
onCancel: () => void;
|
||||||
|
onSubmit: (data: any) => void;
|
||||||
|
initialData?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EventForm: React.FC<EventFormProps> = ({
|
||||||
|
onCancel,
|
||||||
|
onSubmit,
|
||||||
|
initialData,
|
||||||
|
}) => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { institutions, getInstitutionsByUserId, addInstitution } = useData();
|
||||||
|
const [activeTab, setActiveTab] = useState<
|
||||||
|
"details" | "location" | "briefing" | "files"
|
||||||
|
>("details");
|
||||||
|
const [addressQuery, setAddressQuery] = useState("");
|
||||||
|
const [addressResults, setAddressResults] = useState<MapboxResult[]>([]);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [isGeocoding, setIsGeocoding] = useState(false);
|
||||||
|
const [showToast, setShowToast] = useState(false);
|
||||||
|
const [showInstitutionForm, setShowInstitutionForm] = useState(false);
|
||||||
|
|
||||||
|
// Get institutions based on user role
|
||||||
|
// Business owners and admins see all institutions, clients see only their own
|
||||||
|
const userInstitutions = user
|
||||||
|
? user.role === UserRole.BUSINESS_OWNER || user.role === UserRole.SUPERADMIN
|
||||||
|
? institutions
|
||||||
|
: getInstitutionsByUserId(user.id)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Default State or Initial Data
|
||||||
|
const [formData, setFormData] = useState(
|
||||||
|
initialData || {
|
||||||
|
name: "",
|
||||||
|
date: "",
|
||||||
|
time: "",
|
||||||
|
type: "",
|
||||||
|
status: EventStatus.PLANNING,
|
||||||
|
address: {
|
||||||
|
street: "",
|
||||||
|
number: "",
|
||||||
|
city: "",
|
||||||
|
state: "",
|
||||||
|
zip: "",
|
||||||
|
lat: -23.5505,
|
||||||
|
lng: -46.6333,
|
||||||
|
mapLink: "",
|
||||||
|
} as Address,
|
||||||
|
briefing: "",
|
||||||
|
contacts: [{ name: "", role: "", phone: "" }],
|
||||||
|
files: [] as File[],
|
||||||
|
coverImage:
|
||||||
|
"https://images.unsplash.com/photo-1511795409834-ef04bbd61622?ixlib=rb-1.2.1&auto=format&fit=crop&w=800&q=80", // Default
|
||||||
|
institutionId: "",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const isClientRequest = user?.role === UserRole.EVENT_OWNER;
|
||||||
|
const formTitle = initialData
|
||||||
|
? "Editar Evento"
|
||||||
|
: isClientRequest
|
||||||
|
? "Solicitar Orçamento/Evento"
|
||||||
|
: "Cadastrar Novo Evento";
|
||||||
|
const submitLabel = initialData
|
||||||
|
? "Salvar Alterações"
|
||||||
|
: isClientRequest
|
||||||
|
? "Enviar Solicitação"
|
||||||
|
: "Criar Evento";
|
||||||
|
|
||||||
|
// Address Autocomplete Logic using Mapbox
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
if (addressQuery.length > 3) {
|
||||||
|
setIsSearching(true);
|
||||||
|
const results = await searchMapboxLocation(addressQuery);
|
||||||
|
setAddressResults(results);
|
||||||
|
setIsSearching(false);
|
||||||
|
} else {
|
||||||
|
setAddressResults([]);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [addressQuery]);
|
||||||
|
|
||||||
|
const handleAddressSelect = (addr: MapboxResult) => {
|
||||||
|
setFormData((prev: any) => ({
|
||||||
|
...prev,
|
||||||
|
address: {
|
||||||
|
street: addr.street,
|
||||||
|
number: addr.number,
|
||||||
|
city: addr.city,
|
||||||
|
state: addr.state,
|
||||||
|
zip: addr.zip,
|
||||||
|
lat: addr.lat,
|
||||||
|
lng: addr.lng,
|
||||||
|
mapLink: addr.mapLink,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
setAddressQuery("");
|
||||||
|
setAddressResults([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMapLocationChange = async (lat: number, lng: number) => {
|
||||||
|
// Buscar endereço baseado nas coordenadas
|
||||||
|
const addressData = await reverseGeocode(lat, lng);
|
||||||
|
|
||||||
|
if (addressData) {
|
||||||
|
setFormData((prev: any) => ({
|
||||||
|
...prev,
|
||||||
|
address: {
|
||||||
|
street: addressData.street,
|
||||||
|
number: addressData.number,
|
||||||
|
city: addressData.city,
|
||||||
|
state: addressData.state,
|
||||||
|
zip: addressData.zip,
|
||||||
|
lat: addressData.lat,
|
||||||
|
lng: addressData.lng,
|
||||||
|
mapLink: addressData.mapLink,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// Se não conseguir o endereço, atualiza apenas as coordenadas
|
||||||
|
setFormData((prev: any) => ({
|
||||||
|
...prev,
|
||||||
|
address: {
|
||||||
|
...prev.address,
|
||||||
|
lat,
|
||||||
|
lng,
|
||||||
|
mapLink: `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Geocoding quando o usuário digita o endereço manualmente
|
||||||
|
const handleManualAddressChange = async () => {
|
||||||
|
const { street, number, city, state } = formData.address;
|
||||||
|
|
||||||
|
// Montar query de busca
|
||||||
|
const query = `${street} ${number}, ${city}, ${state}`.trim();
|
||||||
|
|
||||||
|
if (query.length < 5) return; // Endereço muito curto
|
||||||
|
|
||||||
|
setIsGeocoding(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await searchMapboxLocation(query);
|
||||||
|
|
||||||
|
if (results.length > 0) {
|
||||||
|
const firstResult = results[0];
|
||||||
|
|
||||||
|
setFormData((prev: any) => ({
|
||||||
|
...prev,
|
||||||
|
address: {
|
||||||
|
...prev.address,
|
||||||
|
lat: firstResult.lat,
|
||||||
|
lng: firstResult.lng,
|
||||||
|
mapLink: firstResult.mapLink,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao geocodificar endereço manual:", error);
|
||||||
|
} finally {
|
||||||
|
setIsGeocoding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addContact = () => {
|
||||||
|
setFormData((prev: any) => ({
|
||||||
|
...prev,
|
||||||
|
contacts: [...prev.contacts, { name: "", role: "", phone: "" }],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeContact = (index: number) => {
|
||||||
|
const newContacts = [...formData.contacts];
|
||||||
|
newContacts.splice(index, 1);
|
||||||
|
setFormData((prev: any) => ({ ...prev, contacts: newContacts }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files) {
|
||||||
|
setFormData((prev: any) => ({
|
||||||
|
...prev,
|
||||||
|
files: [...prev.files, ...Array.from(e.target.files || [])],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
// Validate institution selection
|
||||||
|
if (!formData.institutionId) {
|
||||||
|
alert("Por favor, selecione uma instituição antes de continuar.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show toast
|
||||||
|
setShowToast(true);
|
||||||
|
// Call original submit after small delay for visual effect or immediately
|
||||||
|
setTimeout(() => {
|
||||||
|
onSubmit(formData);
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInstitutionSubmit = (institutionData: any) => {
|
||||||
|
const newInstitution = {
|
||||||
|
...institutionData,
|
||||||
|
id: `inst-${Date.now()}`,
|
||||||
|
ownerId: user?.id || "",
|
||||||
|
};
|
||||||
|
addInstitution(newInstitution);
|
||||||
|
setFormData((prev) => ({ ...prev, institutionId: newInstitution.id }));
|
||||||
|
setShowInstitutionForm(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show institution form modal
|
||||||
|
if (showInstitutionForm) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<InstitutionForm
|
||||||
|
onCancel={() => setShowInstitutionForm(false)}
|
||||||
|
onSubmit={handleInstitutionSubmit}
|
||||||
|
userId={user?.id || ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-xl overflow-hidden max-w-4xl mx-auto border border-gray-100 slide-up relative">
|
||||||
|
{/* Success Toast */}
|
||||||
|
{showToast && (
|
||||||
|
<div className="absolute top-4 right-4 z-50 bg-brand-black text-white px-6 py-4 rounded shadow-2xl flex items-center space-x-3 fade-in">
|
||||||
|
<CheckCircle className="text-brand-gold h-6 w-6" />
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold text-sm">Sucesso!</h4>
|
||||||
|
<p className="text-xs text-gray-300">
|
||||||
|
As informações foram salvas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form Header */}
|
||||||
|
<div className="bg-gray-50 border-b px-8 py-6 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-serif text-brand-black">{formTitle}</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{isClientRequest
|
||||||
|
? "Preencha os detalhes do seu sonho. Nossa equipe analisará em breve."
|
||||||
|
: "Preencha as informações técnicas do evento."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
{["details", "location", "briefing", "files"].map((tab, idx) => (
|
||||||
|
<div
|
||||||
|
key={tab}
|
||||||
|
className={`flex flex-col items-center ${
|
||||||
|
activeTab === tab ? "opacity-100" : "opacity-40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold mb-1 ${
|
||||||
|
activeTab === tab
|
||||||
|
? "bg-brand-black text-white"
|
||||||
|
: "bg-gray-200 text-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{idx + 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 min-h-[500px]">
|
||||||
|
{/* Sidebar Navigation for Form */}
|
||||||
|
<div className="col-span-1 border-r border-gray-100 bg-gray-50/50 p-4 space-y-2">
|
||||||
|
{[
|
||||||
|
{ id: "details", label: "Detalhes & Data" },
|
||||||
|
{ id: "location", label: "Localização" },
|
||||||
|
{
|
||||||
|
id: "briefing",
|
||||||
|
label: isClientRequest ? "Seus Desejos" : "Briefing & Equipe",
|
||||||
|
},
|
||||||
|
{ id: "files", label: "Inspirações" },
|
||||||
|
].map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => setActiveTab(item.id as any)}
|
||||||
|
className={`w-full text-left px-4 py-3 rounded-sm text-sm font-medium transition-colors ${
|
||||||
|
activeTab === item.id
|
||||||
|
? "bg-white shadow-sm text-brand-gold border-l-4 border-brand-gold"
|
||||||
|
: "text-gray-500 hover:bg-gray-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Content */}
|
||||||
|
<div className="col-span-3 p-8">
|
||||||
|
{activeTab === "details" && (
|
||||||
|
<div className="space-y-6 fade-in">
|
||||||
|
<div className="grid grid-cols-1 gap-6">
|
||||||
|
<Input
|
||||||
|
label="Nome do Evento (Opcional)"
|
||||||
|
placeholder="Ex: Casamento Silva & Souza"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, name: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Data Pretendida"
|
||||||
|
type="date"
|
||||||
|
value={formData.date}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, date: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Horário Aproximado"
|
||||||
|
type="time"
|
||||||
|
value={formData.time}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, time: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
label="Tipo de Evento"
|
||||||
|
options={Object.values(EventType).map((t) => ({
|
||||||
|
value: t,
|
||||||
|
label: t,
|
||||||
|
}))}
|
||||||
|
value={formData.type}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, type: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Institution Selection - OBRIGATÓRIO */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1 tracking-wide uppercase text-xs">
|
||||||
|
Universidade*{" "}
|
||||||
|
<span className="text-brand-gold">(Obrigatório)</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{userInstitutions.length === 0 ? (
|
||||||
|
<div className="border-2 border-dashed border-amber-300 bg-amber-50 rounded-sm p-4">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<AlertCircle
|
||||||
|
className="text-amber-600 flex-shrink-0 mt-0.5"
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-amber-900 mb-2">
|
||||||
|
Nenhuma universidade cadastrada
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-amber-700 mb-3">
|
||||||
|
Você precisa cadastrar uma universidade antes de
|
||||||
|
criar um evento. Trabalhamos exclusivamente com
|
||||||
|
eventos fotográficos em universidades.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowInstitutionForm(true)}
|
||||||
|
className="text-xs font-bold text-amber-900 hover:text-amber-700 underline flex items-center"
|
||||||
|
>
|
||||||
|
<Plus size={14} className="mr-1" />
|
||||||
|
Cadastrar minha primeira universidade
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<select
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-sm focus:outline-none focus:ring-1 focus:ring-brand-gold focus:border-brand-gold transition-colors"
|
||||||
|
value={formData.institutionId}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
institutionId: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Selecione uma universidade</option>
|
||||||
|
{userInstitutions.map((inst) => (
|
||||||
|
<option key={inst.id} value={inst.id}>
|
||||||
|
{inst.name} - {inst.type}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowInstitutionForm(true)}
|
||||||
|
className="text-xs text-brand-gold hover:underline flex items-center"
|
||||||
|
>
|
||||||
|
<Plus size={12} className="mr-1" />
|
||||||
|
Cadastrar nova universidade
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{formData.institutionId && (
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-sm p-3 flex items-center">
|
||||||
|
<Check size={16} className="text-green-600 mr-2" />
|
||||||
|
<span className="text-xs text-green-800">
|
||||||
|
Universidade selecionada com sucesso
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cover Image Upload */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1 tracking-wide uppercase text-xs">
|
||||||
|
Imagem de Capa
|
||||||
|
</label>
|
||||||
|
<div className="relative border border-gray-300 rounded-sm p-2 flex items-center bg-white">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.files && e.target.files[0]) {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
const imageUrl = URL.createObjectURL(file);
|
||||||
|
setFormData({ ...formData, coverImage: imageUrl });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between w-full px-2">
|
||||||
|
<span className="text-sm text-gray-500 truncate max-w-[200px]">
|
||||||
|
{formData.coverImage &&
|
||||||
|
!formData.coverImage.startsWith("http")
|
||||||
|
? "Imagem selecionada"
|
||||||
|
: formData.coverImage
|
||||||
|
? "Imagem atual (URL)"
|
||||||
|
: "Clique para selecionar..."}
|
||||||
|
</span>
|
||||||
|
<div className="bg-gray-100 p-1.5 rounded hover:bg-gray-200">
|
||||||
|
<Upload size={16} className="text-gray-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{formData.coverImage && (
|
||||||
|
<div className="mt-2 h-32 w-full rounded-sm overflow-hidden border border-gray-200 relative group">
|
||||||
|
<img
|
||||||
|
src={formData.coverImage}
|
||||||
|
alt="Preview"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-white text-xs">
|
||||||
|
Visualização da Capa
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-8">
|
||||||
|
<Button onClick={() => setActiveTab("location")}>
|
||||||
|
Próximo: Localização
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "location" && (
|
||||||
|
<div className="space-y-6 fade-in">
|
||||||
|
<div className="relative">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1 tracking-wide uppercase text-xs">
|
||||||
|
Busca de Endereço (Powered by Mapbox)
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-sm focus:outline-none focus:ring-1 focus:ring-brand-gold focus:border-brand-gold transition-colors pr-10"
|
||||||
|
placeholder="Digite o nome do local ou endereço..."
|
||||||
|
value={addressQuery}
|
||||||
|
onChange={(e) => setAddressQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="absolute right-3 top-2.5 text-gray-400">
|
||||||
|
{isSearching ? (
|
||||||
|
<div className="animate-spin h-5 w-5 border-2 border-brand-gold rounded-full border-t-transparent"></div>
|
||||||
|
) : (
|
||||||
|
<Search size={20} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{addressResults.length > 0 && (
|
||||||
|
<ul className="absolute z-10 w-full bg-white border mt-1 shadow-lg rounded-sm max-h-64 overflow-y-auto">
|
||||||
|
{addressResults.map((addr, idx) => (
|
||||||
|
<li
|
||||||
|
key={idx}
|
||||||
|
className="px-4 py-3 hover:bg-gray-50 cursor-pointer text-sm border-b border-gray-50 last:border-0"
|
||||||
|
onClick={() => handleAddressSelect(addr)}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex items-start">
|
||||||
|
<MapPin
|
||||||
|
size={16}
|
||||||
|
className="mt-0.5 mr-2 text-brand-gold flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-800">
|
||||||
|
{addr.description}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
{addr.city}, {addr.state}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{addr.mapLink && (
|
||||||
|
<span className="flex items-center text-[10px] text-blue-600 bg-blue-50 px-2 py-1 rounded ml-2">
|
||||||
|
<img
|
||||||
|
src="https://www.google.com/images/branding/product/ico/maps15_bnuw3a_32dp.png"
|
||||||
|
alt="Maps"
|
||||||
|
className="w-3 h-3 mr-1"
|
||||||
|
/>
|
||||||
|
Maps
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<Input
|
||||||
|
label="Rua"
|
||||||
|
value={formData.address.street}
|
||||||
|
onChange={(e) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
address: {
|
||||||
|
...formData.address,
|
||||||
|
street: e.target.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onBlur={handleManualAddressChange}
|
||||||
|
placeholder="Digite o nome da rua"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="Número"
|
||||||
|
placeholder="123"
|
||||||
|
value={formData.address.number}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
address: { ...formData.address, number: value },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onBlur={handleManualAddressChange}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Cidade"
|
||||||
|
value={formData.address.city}
|
||||||
|
onChange={(e) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
address: { ...formData.address, city: e.target.value },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onBlur={handleManualAddressChange}
|
||||||
|
placeholder="Digite a cidade"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Estado"
|
||||||
|
value={formData.address.state}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value.toUpperCase().slice(0, 2);
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
address: { ...formData.address, state: value },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onBlur={handleManualAddressChange}
|
||||||
|
placeholder="SP"
|
||||||
|
maxLength={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mapa Interativo */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-3 tracking-wide uppercase text-xs flex items-center justify-between">
|
||||||
|
<span>📍 Mapa Interativo - Ajuste a Localização Exata</span>
|
||||||
|
{isGeocoding && (
|
||||||
|
<span className="text-xs text-brand-gold flex items-center normal-case">
|
||||||
|
<div className="animate-spin h-3 w-3 border-2 border-brand-gold rounded-full border-t-transparent mr-2"></div>
|
||||||
|
Localizando no mapa...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<MapboxMap
|
||||||
|
initialLat={formData.address.lat || -23.5505}
|
||||||
|
initialLng={formData.address.lng || -46.6333}
|
||||||
|
onLocationChange={handleMapLocationChange}
|
||||||
|
height="450px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.address.mapLink && (
|
||||||
|
<div className="bg-gray-50 p-3 rounded border border-gray-200 flex items-center justify-between">
|
||||||
|
<span className="text-xs text-gray-500 flex items-center">
|
||||||
|
<Check size={14} className="mr-1 text-green-500" />
|
||||||
|
Localização verificada via Mapbox
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href={formData.address.mapLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-xs text-brand-gold flex items-center hover:underline"
|
||||||
|
>
|
||||||
|
Ver no mapa <ExternalLink size={12} className="ml-1" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between mt-8">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setActiveTab("details")}
|
||||||
|
>
|
||||||
|
Voltar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setActiveTab("briefing")}>
|
||||||
|
Próximo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "briefing" && (
|
||||||
|
<div className="space-y-6 fade-in">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1 tracking-wide uppercase text-xs">
|
||||||
|
{isClientRequest
|
||||||
|
? "Conte-nos sobre o seu sonho"
|
||||||
|
: "Briefing Técnico"}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="w-full border border-gray-300 rounded-sm p-3 focus:outline-none focus:border-brand-gold h-32 text-sm"
|
||||||
|
placeholder={
|
||||||
|
isClientRequest
|
||||||
|
? "Qual o estilo do casamento? Quais fotos são indispensáveis? Fale um pouco sobre vocês..."
|
||||||
|
: "Instruções técnicas..."
|
||||||
|
}
|
||||||
|
value={formData.briefing}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, briefing: e.target.value })
|
||||||
|
}
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<label className="text-sm font-medium text-gray-700 tracking-wide uppercase text-xs">
|
||||||
|
Contatos / Responsáveis
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={addContact}
|
||||||
|
className="text-xs text-brand-gold font-bold hover:underline flex items-center"
|
||||||
|
>
|
||||||
|
<Plus size={14} className="mr-1" /> Adicionar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{formData.contacts.map((contact: any, idx: number) => (
|
||||||
|
<div key={idx} className="flex space-x-2 items-start">
|
||||||
|
<Input
|
||||||
|
label={idx === 0 ? "Nome" : ""}
|
||||||
|
placeholder="Nome"
|
||||||
|
value={contact.name}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newContacts = [...formData.contacts];
|
||||||
|
newContacts[idx].name = e.target.value;
|
||||||
|
setFormData({ ...formData, contacts: newContacts });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={idx === 0 ? "Papel" : ""}
|
||||||
|
placeholder="Ex: Cerimonialista"
|
||||||
|
value={contact.role}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newContacts = [...formData.contacts];
|
||||||
|
newContacts[idx].role = e.target.value;
|
||||||
|
setFormData({ ...formData, contacts: newContacts });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => removeContact(idx)}
|
||||||
|
className={`mt-1 p-2 text-gray-400 hover:text-red-500 ${
|
||||||
|
idx === 0 ? "mt-7" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between mt-8">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setActiveTab("location")}
|
||||||
|
>
|
||||||
|
Voltar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setActiveTab("files")}>Próximo</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "files" && (
|
||||||
|
<div className="space-y-6 fade-in">
|
||||||
|
<div className="border-2 border-dashed border-gray-300 rounded-lg p-10 text-center hover:bg-gray-50 transition-colors relative">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
/>
|
||||||
|
<Upload size={40} className="mx-auto text-gray-400 mb-4" />
|
||||||
|
<p className="text-sm text-gray-600 font-medium">
|
||||||
|
{isClientRequest
|
||||||
|
? "Anexe referências visuais (Moodboard)"
|
||||||
|
: "Anexe contratos e cronogramas"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
PDF, JPG, PNG (Max 10MB)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.files.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700">
|
||||||
|
Arquivos Selecionados:
|
||||||
|
</h4>
|
||||||
|
{formData.files.map((file: any, idx: number) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="flex items-center justify-between p-3 bg-gray-50 rounded border border-gray-100"
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<FileText size={18} className="text-brand-gold mr-3" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{file.name}</p>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{(file.size / 1024).toFixed(1)} KB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Check size={16} className="text-green-500" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between mt-8">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setActiveTab("briefing")}
|
||||||
|
>
|
||||||
|
Voltar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} variant="secondary">
|
||||||
|
{submitLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
120
frontend/components/Input.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Eye, EyeOff } from 'lucide-react';
|
||||||
|
|
||||||
|
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label: string;
|
||||||
|
error?: string;
|
||||||
|
mask?: 'phone' | 'cnpj' | 'cep';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Input: React.FC<InputProps> = ({ label, error, className = '', type, mask, onChange, ...props }) => {
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const isPassword = type === 'password';
|
||||||
|
const inputType = isPassword && showPassword ? 'text' : type;
|
||||||
|
|
||||||
|
const applyMask = (value: string, maskType?: 'phone' | 'cnpj' | 'cep') => {
|
||||||
|
if (!maskType) return value;
|
||||||
|
|
||||||
|
const numbers = value.replace(/\D/g, '');
|
||||||
|
|
||||||
|
switch (maskType) {
|
||||||
|
case 'phone':
|
||||||
|
// Limita a 11 dígitos (celular)
|
||||||
|
const phoneNumbers = numbers.slice(0, 11);
|
||||||
|
if (phoneNumbers.length <= 10) {
|
||||||
|
return phoneNumbers.replace(/(\d{2})(\d{4})(\d{0,4})/, '($1) $2-$3');
|
||||||
|
}
|
||||||
|
return phoneNumbers.replace(/(\d{2})(\d{5})(\d{0,4})/, '($1) $2-$3');
|
||||||
|
|
||||||
|
case 'cnpj':
|
||||||
|
// Limita a 14 dígitos
|
||||||
|
const cnpjNumbers = numbers.slice(0, 14);
|
||||||
|
return cnpjNumbers.replace(/(\d{2})(\d{3})(\d{3})(\d{4})(\d{0,2})/, '$1.$2.$3/$4-$5');
|
||||||
|
|
||||||
|
case 'cep':
|
||||||
|
// Limita a 8 dígitos
|
||||||
|
const cepNumbers = numbers.slice(0, 8);
|
||||||
|
return cepNumbers.replace(/(\d{5})(\d{0,3})/, '$1-$2');
|
||||||
|
|
||||||
|
default:
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (mask) {
|
||||||
|
const maskedValue = applyMask(e.target.value, mask);
|
||||||
|
e.target.value = maskedValue;
|
||||||
|
}
|
||||||
|
onChange?.(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define maxLength baseado na máscara
|
||||||
|
const getMaxLength = () => {
|
||||||
|
if (!mask) return undefined;
|
||||||
|
switch (mask) {
|
||||||
|
case 'phone': return 15; // (00) 00000-0000
|
||||||
|
case 'cnpj': return 18; // 00.000.000/0000-00
|
||||||
|
case 'cep': return 9; // 00000-000
|
||||||
|
default: return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1 tracking-wide uppercase text-xs">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
className={`w-full px-4 py-2 border rounded-sm focus:outline-none focus:ring-1 focus:ring-brand-gold focus:border-brand-gold transition-colors
|
||||||
|
${error ? 'border-red-500' : 'border-gray-300'}
|
||||||
|
${isPassword ? 'pr-10' : ''}
|
||||||
|
${className}`}
|
||||||
|
type={inputType}
|
||||||
|
onChange={handleChange}
|
||||||
|
maxLength={getMaxLength()}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{isPassword && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && <span className="text-xs text-red-500 mt-1">{error}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
|
label: string;
|
||||||
|
options: { value: string; label: string }[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Select: React.FC<SelectProps> = ({ label, options, error, className = '', ...props }) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1 tracking-wide uppercase text-xs">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className={`w-full px-4 py-2 border rounded-sm focus:outline-none focus:ring-1 focus:ring-brand-gold focus:border-brand-gold transition-colors appearance-none bg-white
|
||||||
|
${error ? 'border-red-500' : 'border-gray-300'}
|
||||||
|
${className}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<option value="" disabled>Selecione uma opção</option>
|
||||||
|
{options.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{error && <span className="text-xs text-red-500 mt-1">{error}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
248
frontend/components/InstitutionForm.tsx
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Institution, Address } from '../types';
|
||||||
|
import { Input, Select } from './Input';
|
||||||
|
import { Button } from './Button';
|
||||||
|
import { Building2, X, Check } from 'lucide-react';
|
||||||
|
|
||||||
|
interface InstitutionFormProps {
|
||||||
|
onCancel: () => void;
|
||||||
|
onSubmit: (data: Partial<Institution>) => void;
|
||||||
|
initialData?: Institution;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INSTITUTION_TYPES = [
|
||||||
|
'Universidade Pública',
|
||||||
|
'Universidade Privada',
|
||||||
|
'Faculdade',
|
||||||
|
'Instituto Federal',
|
||||||
|
'Centro Universitário',
|
||||||
|
'Campus Universitário'
|
||||||
|
];
|
||||||
|
|
||||||
|
export const InstitutionForm: React.FC<InstitutionFormProps> = ({
|
||||||
|
onCancel,
|
||||||
|
onSubmit,
|
||||||
|
initialData,
|
||||||
|
userId
|
||||||
|
}) => {
|
||||||
|
const [formData, setFormData] = useState<Partial<Institution>>(initialData || {
|
||||||
|
name: '',
|
||||||
|
type: '',
|
||||||
|
cnpj: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
description: '',
|
||||||
|
ownerId: userId,
|
||||||
|
address: {
|
||||||
|
street: '',
|
||||||
|
number: '',
|
||||||
|
city: '',
|
||||||
|
state: '',
|
||||||
|
zip: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const [showToast, setShowToast] = useState(false);
|
||||||
|
const [stateError, setStateError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowToast(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
onSubmit(formData);
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (field: keyof Institution, value: any) => {
|
||||||
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddressChange = (field: keyof Address, value: string) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
address: {
|
||||||
|
...prev.address!,
|
||||||
|
[field]: value
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-xl overflow-hidden max-w-2xl mx-auto border border-gray-100 slide-up relative">
|
||||||
|
|
||||||
|
{/* Success Toast */}
|
||||||
|
{showToast && (
|
||||||
|
<div className="absolute top-4 right-4 z-50 bg-brand-black text-white px-6 py-4 rounded shadow-2xl flex items-center space-x-3 fade-in">
|
||||||
|
<Check className="text-brand-gold h-6 w-6" />
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold text-sm">Sucesso!</h4>
|
||||||
|
<p className="text-xs text-gray-300">Universidade cadastrada com sucesso.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form Header */}
|
||||||
|
<div className="bg-gray-50 border-b px-8 py-6 flex justify-between items-center">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Building2 className="text-brand-gold h-8 w-8" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-serif text-brand-black">
|
||||||
|
{initialData ? 'Editar Universidade' : 'Cadastrar Universidade'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Registre a universidade onde os eventos fotográficos serão realizados
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="p-2 hover:bg-gray-200 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} className="text-gray-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-8 space-y-6">
|
||||||
|
|
||||||
|
{/* Informações Básicas */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 tracking-wide uppercase">
|
||||||
|
Informações Básicas
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Nome da Universidade*"
|
||||||
|
placeholder="Ex: Universidade Federal do Rio Grande do Sul"
|
||||||
|
value={formData.name || ''}
|
||||||
|
onChange={(e) => handleChange('name', e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Select
|
||||||
|
label="Tipo de Universidade*"
|
||||||
|
options={INSTITUTION_TYPES.map(t => ({ value: t, label: t }))}
|
||||||
|
value={formData.type || ''}
|
||||||
|
onChange={(e) => handleChange('type', e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="CNPJ (Opcional)"
|
||||||
|
placeholder="00.000.000/0000-00"
|
||||||
|
value={formData.cnpj || ''}
|
||||||
|
onChange={(e) => handleChange('cnpj', e.target.value)}
|
||||||
|
mask="cnpj"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Telefone*"
|
||||||
|
type="tel"
|
||||||
|
placeholder="(00) 00000-0000"
|
||||||
|
value={formData.phone || ''}
|
||||||
|
onChange={(e) => handleChange('phone', e.target.value)}
|
||||||
|
mask="phone"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="E-mail*"
|
||||||
|
type="email"
|
||||||
|
placeholder="contato@instituicao.com"
|
||||||
|
value={formData.email || ''}
|
||||||
|
onChange={(e) => handleChange('email', e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1 tracking-wide uppercase text-xs">
|
||||||
|
Descrição (Opcional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="w-full border border-gray-300 rounded-sm p-3 focus:outline-none focus:border-brand-gold h-24 text-sm"
|
||||||
|
placeholder="Ex: Campus principal, informações sobre o campus, áreas para eventos..."
|
||||||
|
value={formData.description || ''}
|
||||||
|
onChange={(e) => handleChange('description', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Endereço */}
|
||||||
|
<div className="space-y-4 border-t pt-6">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 tracking-wide uppercase">
|
||||||
|
Endereço (Opcional)
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<Input
|
||||||
|
label="Rua"
|
||||||
|
placeholder="Nome da rua"
|
||||||
|
value={formData.address?.street || ''}
|
||||||
|
onChange={(e) => handleAddressChange('street', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="Número"
|
||||||
|
placeholder="123"
|
||||||
|
value={formData.address?.number || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value.replace(/\D/g, '');
|
||||||
|
handleAddressChange('number', value);
|
||||||
|
}}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Cidade"
|
||||||
|
placeholder="Cidade"
|
||||||
|
value={formData.address?.city || ''}
|
||||||
|
onChange={(e) => handleAddressChange('city', e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Estado"
|
||||||
|
placeholder="UF"
|
||||||
|
value={formData.address?.state || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const hasNumbers = /[0-9]/.test(e.target.value);
|
||||||
|
if (hasNumbers) {
|
||||||
|
setStateError('O campo Estado aceita apenas letras');
|
||||||
|
setTimeout(() => setStateError(''), 3000);
|
||||||
|
}
|
||||||
|
const value = e.target.value.replace(/[0-9]/g, '').toUpperCase();
|
||||||
|
handleAddressChange('state', value);
|
||||||
|
}}
|
||||||
|
maxLength={2}
|
||||||
|
error={stateError}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="CEP"
|
||||||
|
placeholder="00000-000"
|
||||||
|
value={formData.address?.zip || ''}
|
||||||
|
onChange={(e) => handleAddressChange('zip', e.target.value)}
|
||||||
|
mask="cep"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end space-x-3 pt-6 border-t">
|
||||||
|
<Button variant="outline" onClick={onCancel} type="button">
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="secondary">
|
||||||
|
{initialData ? 'Salvar Alterações' : 'Cadastrar Universidade'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
225
frontend/components/MapboxMap.tsx
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import mapboxgl from "mapbox-gl";
|
||||||
|
import "mapbox-gl/dist/mapbox-gl.css";
|
||||||
|
import { MapPin, Target } from "lucide-react";
|
||||||
|
|
||||||
|
interface MapboxMapProps {
|
||||||
|
initialLat?: number;
|
||||||
|
initialLng?: number;
|
||||||
|
onLocationChange?: (lat: number, lng: number) => void;
|
||||||
|
height?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MapboxMap: React.FC<MapboxMapProps> = ({
|
||||||
|
initialLat = -23.5505, // São Paulo como padrão
|
||||||
|
initialLng = -46.6333,
|
||||||
|
onLocationChange,
|
||||||
|
height = "400px",
|
||||||
|
}) => {
|
||||||
|
const mapContainer = useRef<HTMLDivElement>(null);
|
||||||
|
const map = useRef<mapboxgl.Map | null>(null);
|
||||||
|
const marker = useRef<mapboxgl.Marker | null>(null);
|
||||||
|
const [currentLat, setCurrentLat] = useState(initialLat);
|
||||||
|
const [currentLng, setCurrentLng] = useState(initialLng);
|
||||||
|
const [mapLoaded, setMapLoaded] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapContainer.current || map.current) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("🗺️ Inicializando mapa Mapbox...");
|
||||||
|
// Configurar token
|
||||||
|
const token = import.meta.env.VITE_MAPBOX_TOKEN;
|
||||||
|
if (!token) {
|
||||||
|
setError(
|
||||||
|
"❌ Token do Mapbox não encontrado. Configure VITE_MAPBOX_TOKEN no arquivo .env.local"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mapboxgl.accessToken = token;
|
||||||
|
|
||||||
|
// Inicializar mapa
|
||||||
|
map.current = new mapboxgl.Map({
|
||||||
|
container: mapContainer.current,
|
||||||
|
style: "mapbox://styles/mapbox/streets-v12",
|
||||||
|
center: [initialLng, initialLat],
|
||||||
|
zoom: 15,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("✅ Mapa criado com sucesso");
|
||||||
|
|
||||||
|
// Adicionar controles de navegação
|
||||||
|
map.current.addControl(new mapboxgl.NavigationControl(), "top-right");
|
||||||
|
|
||||||
|
// Adicionar controle de localização
|
||||||
|
map.current.addControl(
|
||||||
|
new mapboxgl.GeolocateControl({
|
||||||
|
positionOptions: {
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
},
|
||||||
|
trackUserLocation: true,
|
||||||
|
showUserHeading: true,
|
||||||
|
}),
|
||||||
|
"top-right"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Criar marcador arrastável
|
||||||
|
marker.current = new mapboxgl.Marker({
|
||||||
|
color: "#c5a059",
|
||||||
|
draggable: true,
|
||||||
|
})
|
||||||
|
.setLngLat([initialLng, initialLat])
|
||||||
|
.addTo(map.current);
|
||||||
|
|
||||||
|
// Evento quando o marcador é arrastado
|
||||||
|
marker.current.on("dragend", () => {
|
||||||
|
if (marker.current) {
|
||||||
|
const lngLat = marker.current.getLngLat();
|
||||||
|
setCurrentLat(lngLat.lat);
|
||||||
|
setCurrentLng(lngLat.lng);
|
||||||
|
if (onLocationChange) {
|
||||||
|
onLocationChange(lngLat.lat, lngLat.lng);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Evento de clique no mapa para mover o marcador
|
||||||
|
map.current.on("click", (e) => {
|
||||||
|
if (marker.current) {
|
||||||
|
marker.current.setLngLat([e.lngLat.lng, e.lngLat.lat]);
|
||||||
|
setCurrentLat(e.lngLat.lat);
|
||||||
|
setCurrentLng(e.lngLat.lng);
|
||||||
|
if (onLocationChange) {
|
||||||
|
onLocationChange(e.lngLat.lat, e.lngLat.lng);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
map.current.on("load", () => {
|
||||||
|
setMapLoaded(true);
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Erro ao inicializar mapa:", error);
|
||||||
|
const errorMsg = error?.message || String(error);
|
||||||
|
if (
|
||||||
|
errorMsg.includes("token") ||
|
||||||
|
errorMsg.includes("Unauthorized") ||
|
||||||
|
errorMsg.includes("401")
|
||||||
|
) {
|
||||||
|
setError(
|
||||||
|
"❌ Token do Mapbox inválido. Obtenha um token gratuito em https://account.mapbox.com/ e configure em services/mapboxService.ts"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setError(`Erro ao carregar o mapa: ${errorMsg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
return () => {
|
||||||
|
if (marker.current) {
|
||||||
|
marker.current.remove();
|
||||||
|
}
|
||||||
|
if (map.current) {
|
||||||
|
map.current.remove();
|
||||||
|
map.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []); // Executar apenas uma vez
|
||||||
|
|
||||||
|
// Atualizar posição do marcador quando as coordenadas mudarem externamente
|
||||||
|
useEffect(() => {
|
||||||
|
if (marker.current && map.current && mapLoaded) {
|
||||||
|
marker.current.setLngLat([initialLng, initialLat]);
|
||||||
|
map.current.flyTo({
|
||||||
|
center: [initialLng, initialLat],
|
||||||
|
zoom: 15,
|
||||||
|
duration: 1500,
|
||||||
|
});
|
||||||
|
setCurrentLat(initialLat);
|
||||||
|
setCurrentLng(initialLng);
|
||||||
|
}
|
||||||
|
}, [initialLat, initialLng, mapLoaded]);
|
||||||
|
|
||||||
|
const centerOnMarker = () => {
|
||||||
|
if (map.current && marker.current) {
|
||||||
|
const lngLat = marker.current.getLngLat();
|
||||||
|
map.current.flyTo({
|
||||||
|
center: [lngLat.lng, lngLat.lat],
|
||||||
|
zoom: 17,
|
||||||
|
duration: 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{error && (
|
||||||
|
<div className="absolute top-0 left-0 right-0 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded z-10">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={mapContainer}
|
||||||
|
className="w-full rounded-lg border-2 border-gray-300 overflow-hidden shadow-md"
|
||||||
|
style={{ height }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Info overlay - Responsivo */}
|
||||||
|
<div className="absolute bottom-2 sm:bottom-4 left-2 sm:left-4 bg-white/95 backdrop-blur-sm px-2 py-2 sm:px-4 sm:py-3 rounded-lg shadow-lg border border-gray-200 max-w-[160px] sm:max-w-none">
|
||||||
|
<div className="flex items-center space-x-2 sm:space-x-3">
|
||||||
|
<MapPin
|
||||||
|
size={16}
|
||||||
|
className="text-brand-gold flex-shrink-0 hidden sm:block"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[10px] sm:text-xs text-gray-500 font-medium hidden sm:block">
|
||||||
|
Coordenadas
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] sm:text-sm font-mono text-gray-800 truncate">
|
||||||
|
{currentLat.toFixed(4)}, {currentLng.toFixed(4)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Botão de centralizar - Responsivo */}
|
||||||
|
<button
|
||||||
|
onClick={centerOnMarker}
|
||||||
|
className="absolute bottom-2 sm:bottom-4 right-2 sm:right-4 bg-white hover:bg-gray-50 p-2 sm:p-3 rounded-full shadow-lg border border-gray-200 transition-colors group"
|
||||||
|
title="Centralizar no marcador"
|
||||||
|
>
|
||||||
|
<Target
|
||||||
|
size={18}
|
||||||
|
className="text-gray-600 group-hover:text-brand-gold transition-colors sm:w-5 sm:h-5"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Instruções - Responsivo */}
|
||||||
|
<div className="mt-3 bg-blue-50 border border-blue-200 rounded-lg p-2 sm:p-3 text-xs sm:text-sm text-blue-800">
|
||||||
|
<p className="font-medium mb-1 text-xs sm:text-sm">💡 Como usar:</p>
|
||||||
|
<ul className="text-[11px] sm:text-xs space-y-0.5 sm:space-y-1 text-blue-700">
|
||||||
|
<li className="flex items-start">
|
||||||
|
<span className="mr-1">•</span>
|
||||||
|
<span>
|
||||||
|
<strong>Arraste o marcador</strong> para a posição exata
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start">
|
||||||
|
<span className="mr-1">•</span>
|
||||||
|
<span>
|
||||||
|
<strong>Clique no mapa</strong> para mover o marcador
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start hidden sm:flex">
|
||||||
|
<span className="mr-1">•</span>
|
||||||
|
<span>
|
||||||
|
Use os <strong>controles</strong> para zoom e navegação
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
255
frontend/components/Navbar.tsx
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { UserRole } from "../types";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import { Menu, X, LogOut, User } from "lucide-react";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
|
||||||
|
interface NavbarProps {
|
||||||
|
onNavigate: (page: string) => void;
|
||||||
|
currentPage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Navbar: React.FC<NavbarProps> = ({ onNavigate, currentPage }) => {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
const [isAccountDropdownOpen, setIsAccountDropdownOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
setIsScrolled(window.scrollY > 20);
|
||||||
|
};
|
||||||
|
window.addEventListener("scroll", handleScroll);
|
||||||
|
return () => window.removeEventListener("scroll", handleScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getLinks = () => {
|
||||||
|
if (!user) return [];
|
||||||
|
|
||||||
|
switch (user.role) {
|
||||||
|
case UserRole.SUPERADMIN:
|
||||||
|
case UserRole.BUSINESS_OWNER:
|
||||||
|
return [
|
||||||
|
{ name: "Gestão de Eventos", path: "dashboard" },
|
||||||
|
{ name: "Equipe & Fotógrafos", path: "team" },
|
||||||
|
{ name: "Financeiro", path: "finance" },
|
||||||
|
{ name: "Configurações", path: "settings" },
|
||||||
|
];
|
||||||
|
case UserRole.EVENT_OWNER:
|
||||||
|
return [
|
||||||
|
{ name: "Meus Eventos", path: "dashboard" },
|
||||||
|
{ name: "Solicitar Evento", path: "request-event" },
|
||||||
|
];
|
||||||
|
case UserRole.PHOTOGRAPHER:
|
||||||
|
return [
|
||||||
|
{ name: "Eventos Designados", path: "dashboard" },
|
||||||
|
{ name: "Agenda", path: "calendar" },
|
||||||
|
];
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRoleLabel = () => {
|
||||||
|
if (!user) return "";
|
||||||
|
if (user.role === UserRole.BUSINESS_OWNER) return "Empresa";
|
||||||
|
if (user.role === UserRole.EVENT_OWNER) return "Cliente";
|
||||||
|
if (user.role === UserRole.PHOTOGRAPHER) return "Fotógrafo";
|
||||||
|
if (user.role === UserRole.SUPERADMIN) return "Super Admin";
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="fixed w-full z-50 bg-white shadow-sm py-3">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-between items-center h-16">
|
||||||
|
{/* Logo */}
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 flex items-center cursor-pointer"
|
||||||
|
onClick={() => onNavigate("home")}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/logo.png"
|
||||||
|
alt="Photum Formaturas"
|
||||||
|
className="h-30 mb-6 w-auto object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop Navigation */}
|
||||||
|
{user && (
|
||||||
|
<div className="hidden md:flex items-center space-x-6">
|
||||||
|
{getLinks().map((link) => (
|
||||||
|
<button
|
||||||
|
key={link.path}
|
||||||
|
onClick={() => onNavigate(link.path)}
|
||||||
|
className={`text-sm font-medium tracking-wide uppercase hover:text-brand-gold transition-colors pb-1 ${
|
||||||
|
currentPage === link.path
|
||||||
|
? "text-brand-gold border-b-2 border-brand-gold"
|
||||||
|
: "text-gray-600 border-b-2 border-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{link.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Right Side Actions */}
|
||||||
|
<div className="hidden md:flex items-center space-x-4">
|
||||||
|
{user ? (
|
||||||
|
<div className="flex items-center space-x-4 pl-4 border-l border-gray-200">
|
||||||
|
<div className="flex flex-col items-end mr-2">
|
||||||
|
<span className="text-sm font-bold text-brand-black leading-tight">
|
||||||
|
{user.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-brand-gold leading-tight">
|
||||||
|
{getRoleLabel()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-9 w-9 rounded-full bg-gray-100 overflow-hidden border border-gray-200 ring-2 ring-transparent hover:ring-brand-gold transition-all">
|
||||||
|
<img
|
||||||
|
src={user.avatar}
|
||||||
|
alt="Avatar"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="text-gray-400 hover:text-red-500 transition-colors p-1"
|
||||||
|
title="Sair"
|
||||||
|
>
|
||||||
|
<LogOut size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setIsAccountDropdownOpen(!isAccountDropdownOpen)
|
||||||
|
}
|
||||||
|
className="flex items-center gap-2 p-2 rounded-full hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-full border-2 border-brand-gold flex items-center justify-center text-brand-gold hover:bg-brand-gold hover:text-white transition-colors">
|
||||||
|
<User size={24} />
|
||||||
|
</div>
|
||||||
|
<div className="text-left hidden lg:block">
|
||||||
|
<p className="text-xs text-gray-500">Olá, bem-vindo(a)</p>
|
||||||
|
<p className="text-sm font-medium text-brand-black">
|
||||||
|
Entrar / Cadastrar
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown Popup - Centralizado */}
|
||||||
|
{isAccountDropdownOpen && (
|
||||||
|
<div className="absolute left-1/2 -translate-x-1/2 top-full mt-2 w-64 bg-white rounded-xl shadow-xl border border-gray-200 overflow-hidden z-50 fade-in">
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
onNavigate("login");
|
||||||
|
setIsAccountDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full rounded-xl"
|
||||||
|
>
|
||||||
|
ENTRAR
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
onNavigate("register");
|
||||||
|
setIsAccountDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full bg-purple-600 text-white hover:bg-purple-700 focus:ring-purple-500 rounded-xl"
|
||||||
|
>
|
||||||
|
Cadastre-se agora
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Button */}
|
||||||
|
<div className="md:hidden flex items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||||
|
className="text-brand-black hover:text-brand-gold p-2"
|
||||||
|
>
|
||||||
|
{isMobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Menu */}
|
||||||
|
{isMobileMenuOpen && (
|
||||||
|
<div className="md:hidden absolute top-full left-0 w-full bg-white border-b border-gray-100 shadow-lg fade-in">
|
||||||
|
<div className="px-4 py-4 space-y-3">
|
||||||
|
{user &&
|
||||||
|
getLinks().map((link) => (
|
||||||
|
<button
|
||||||
|
key={link.path}
|
||||||
|
onClick={() => {
|
||||||
|
onNavigate(link.path);
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
}}
|
||||||
|
className="block w-full text-left text-base font-medium text-gray-700 hover:text-brand-gold py-2 border-b border-gray-50"
|
||||||
|
>
|
||||||
|
{link.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div className="pt-4">
|
||||||
|
{user ? (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<img
|
||||||
|
src={user.avatar}
|
||||||
|
className="w-8 h-8 rounded-full mr-2"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="font-bold text-sm block">
|
||||||
|
{user.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-brand-gold">
|
||||||
|
{getRoleLabel()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={logout}>
|
||||||
|
Sair
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
className="w-full rounded-lg"
|
||||||
|
size="lg"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
onNavigate("login");
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ENTRAR
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="w-full bg-purple-600 text-white hover:bg-purple-700 focus:ring-purple-500 rounded-lg"
|
||||||
|
size="lg"
|
||||||
|
onClick={() => {
|
||||||
|
onNavigate("register");
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cadastre-se agora
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
179
frontend/contexts/DataContext.tsx
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
import React, { createContext, useContext, useState, ReactNode } from "react";
|
||||||
|
import { EventData, EventStatus, EventType, Institution } from "../types";
|
||||||
|
|
||||||
|
// Initial Mock Data
|
||||||
|
const INITIAL_INSTITUTIONS: Institution[] = [
|
||||||
|
{
|
||||||
|
id: "inst-1",
|
||||||
|
name: "Universidade Federal do Rio Grande do Sul",
|
||||||
|
type: "Universidade Pública",
|
||||||
|
phone: "(51) 3308-3333",
|
||||||
|
email: "eventos@ufrgs.br",
|
||||||
|
address: {
|
||||||
|
street: "Av. Paulo Gama",
|
||||||
|
number: "110",
|
||||||
|
city: "Porto Alegre",
|
||||||
|
state: "RS",
|
||||||
|
zip: "90040-060",
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
"Campus Central - Principais eventos realizados no Salão de Atos",
|
||||||
|
ownerId: "client-1",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const INITIAL_EVENTS: EventData[] = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
name: "Casamento Juliana & Marcos",
|
||||||
|
date: "2024-10-15",
|
||||||
|
time: "16:00",
|
||||||
|
type: EventType.WEDDING,
|
||||||
|
status: EventStatus.CONFIRMED,
|
||||||
|
address: {
|
||||||
|
street: "Av. das Hortênsias",
|
||||||
|
number: "1200",
|
||||||
|
city: "Gramado",
|
||||||
|
state: "RS",
|
||||||
|
zip: "95670-000",
|
||||||
|
},
|
||||||
|
briefing:
|
||||||
|
"Cerimônia ao pôr do sol. Foco em fotos espontâneas dos noivos e pais.",
|
||||||
|
coverImage: "https://picsum.photos/id/1059/800/400",
|
||||||
|
contacts: [
|
||||||
|
{
|
||||||
|
id: "c1",
|
||||||
|
name: "Cerimonial Silva",
|
||||||
|
role: "Cerimonialista",
|
||||||
|
phone: "9999-9999",
|
||||||
|
email: "c@teste.com",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
checklist: [],
|
||||||
|
ownerId: "client-1",
|
||||||
|
photographerIds: ["photographer-1"],
|
||||||
|
institutionId: "inst-1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
name: "Conferência Tech Innovators",
|
||||||
|
date: "2024-11-05",
|
||||||
|
time: "08:00",
|
||||||
|
type: EventType.CORPORATE,
|
||||||
|
status: EventStatus.PENDING_APPROVAL,
|
||||||
|
address: {
|
||||||
|
street: "Rua Olimpíadas",
|
||||||
|
number: "205",
|
||||||
|
city: "São Paulo",
|
||||||
|
state: "SP",
|
||||||
|
zip: "04551-000",
|
||||||
|
},
|
||||||
|
briefing: "Cobrir palestras principais e networking no coffee break.",
|
||||||
|
coverImage: "https://picsum.photos/id/3/800/400",
|
||||||
|
contacts: [],
|
||||||
|
checklist: [],
|
||||||
|
ownerId: "client-2", // Other client
|
||||||
|
photographerIds: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface DataContextType {
|
||||||
|
events: EventData[];
|
||||||
|
institutions: Institution[];
|
||||||
|
addEvent: (event: EventData) => void;
|
||||||
|
updateEventStatus: (id: string, status: EventStatus) => void;
|
||||||
|
assignPhotographer: (eventId: string, photographerId: string) => void;
|
||||||
|
getEventsByRole: (userId: string, role: string) => EventData[];
|
||||||
|
addInstitution: (institution: Institution) => void;
|
||||||
|
updateInstitution: (id: string, institution: Partial<Institution>) => void;
|
||||||
|
getInstitutionsByUserId: (userId: string) => Institution[];
|
||||||
|
getInstitutionById: (id: string) => Institution | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DataContext = createContext<DataContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const DataProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const [events, setEvents] = useState<EventData[]>(INITIAL_EVENTS);
|
||||||
|
const [institutions, setInstitutions] =
|
||||||
|
useState<Institution[]>(INITIAL_INSTITUTIONS);
|
||||||
|
|
||||||
|
const addEvent = (event: EventData) => {
|
||||||
|
setEvents((prev) => [event, ...prev]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateEventStatus = (id: string, status: EventStatus) => {
|
||||||
|
setEvents((prev) => prev.map((e) => (e.id === id ? { ...e, status } : e)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const assignPhotographer = (eventId: string, photographerId: string) => {
|
||||||
|
setEvents((prev) =>
|
||||||
|
prev.map((e) => {
|
||||||
|
if (e.id === eventId) {
|
||||||
|
const current = e.photographerIds || [];
|
||||||
|
if (!current.includes(photographerId)) {
|
||||||
|
return { ...e, photographerIds: [...current, photographerId] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEventsByRole = (userId: string, role: string) => {
|
||||||
|
if (role === "SUPERADMIN" || role === "BUSINESS_OWNER") {
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
if (role === "EVENT_OWNER") {
|
||||||
|
return events.filter((e) => e.ownerId === userId);
|
||||||
|
}
|
||||||
|
if (role === "PHOTOGRAPHER") {
|
||||||
|
return events.filter((e) => e.photographerIds.includes(userId));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const addInstitution = (institution: Institution) => {
|
||||||
|
setInstitutions((prev) => [...prev, institution]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateInstitution = (id: string, updatedData: Partial<Institution>) => {
|
||||||
|
setInstitutions((prev) =>
|
||||||
|
prev.map((inst) => (inst.id === id ? { ...inst, ...updatedData } : inst))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInstitutionsByUserId = (userId: string) => {
|
||||||
|
return institutions.filter((inst) => inst.ownerId === userId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInstitutionById = (id: string) => {
|
||||||
|
return institutions.find((inst) => inst.id === id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataContext.Provider
|
||||||
|
value={{
|
||||||
|
events,
|
||||||
|
institutions,
|
||||||
|
addEvent,
|
||||||
|
updateEventStatus,
|
||||||
|
assignPhotographer,
|
||||||
|
getEventsByRole,
|
||||||
|
addInstitution,
|
||||||
|
updateInstitution,
|
||||||
|
getInstitutionsByUserId,
|
||||||
|
getInstitutionById,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DataContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useData = () => {
|
||||||
|
const context = useContext(DataContext);
|
||||||
|
if (!context) throw new Error("useData must be used within a DataProvider");
|
||||||
|
return context;
|
||||||
|
};
|
||||||
249
frontend/index.html
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>PhotumFormaturas</title>
|
||||||
|
|
||||||
|
<!-- Mapbox GL CSS (versão CDN confiável) -->
|
||||||
|
<link href='https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css' rel='stylesheet' />
|
||||||
|
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Playfair+Display:ital,wght@0,400;0,600;1,400&display=swap"
|
||||||
|
rel="stylesheet">
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'sans-serif'],
|
||||||
|
serif: ['Playfair Display', 'serif'],
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
black: '#1a1a1a',
|
||||||
|
gold: '#B9CF33',
|
||||||
|
gray: '#f4f4f4',
|
||||||
|
darkgray: '#333333'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #B9CF33;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #a5bd2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading Screen */
|
||||||
|
#loading-screen {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(135deg, #492E61 0%, #6B4694 100%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
animation: fadeOut 0.5s ease-out 2.5s forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeOut {
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-logo {
|
||||||
|
width: 200px;
|
||||||
|
height: auto;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
animation: scaleIn 0.8s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scaleIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
color: white;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 300;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
animation: fadeInUp 0.8s ease-out 0.3s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-bar {
|
||||||
|
width: 200px;
|
||||||
|
height: 3px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: fadeInUp 0.8s ease-out 0.5s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #B9CF33, #C2388B);
|
||||||
|
border-radius: 10px;
|
||||||
|
animation: loadingProgress 2s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loadingProgress {
|
||||||
|
from {
|
||||||
|
width: 0%;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeIn 0.5s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-up {
|
||||||
|
animation: slideUp 0.6s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page entrance animation */
|
||||||
|
#root {
|
||||||
|
animation: pageEntrance 0.5s ease-out 2.5s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pageEntrance {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mapbox custom styles */
|
||||||
|
.mapboxgl-ctrl-group {
|
||||||
|
border-radius: 8px !important;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapboxgl-ctrl-group button {
|
||||||
|
width: 36px !important;
|
||||||
|
height: 36px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapboxgl-ctrl-geolocate {
|
||||||
|
background-color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon {
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='%23333' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 2v20M2 12h20'/%3E%3Ccircle cx='12' cy='12' r='4'/%3E%3C/svg%3E") !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-marker {
|
||||||
|
cursor: grab !important;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-marker:active {
|
||||||
|
cursor: grabbing !important;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapboxgl-popup {
|
||||||
|
max-width: 300px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapboxgl-popup-content {
|
||||||
|
border-radius: 8px !important;
|
||||||
|
padding: 15px !important;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="bg-white text-brand-black antialiased selection:bg-brand-gold selection:text-white">
|
||||||
|
<!-- Loading Screen -->
|
||||||
|
<div id="loading-screen">
|
||||||
|
<img src="/logo.png" alt="Photum Formaturas" class="loading-logo" />
|
||||||
|
<div class="loading-text">PHOTUM FORMATURAS</div>
|
||||||
|
<div class="loading-bar">
|
||||||
|
<div class="loading-bar-fill"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/index.tsx"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
15
frontend/index.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
|
||||||
|
const rootElement = document.getElementById("root");
|
||||||
|
if (!rootElement) {
|
||||||
|
throw new Error("Could not find root element");
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(rootElement);
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "PhotumManager",
|
"name": "PhotumFormaturas",
|
||||||
"description": "Sistema de gerenciamento de eventos premium inspirado na identidade visual do Photum.com.br. Foco em experiência do usuário, design minimalista e funcionalidades robustas para fotógrafos e donos de eventos.",
|
"description": "Sistema de gerenciamento de eventos premium inspirado na identidade visual do Photum.com.br. Foco em experiência do usuário, design minimalista e funcionalidades robustas para fotógrafos e donos de eventos.",
|
||||||
"requestFramePermissions": [
|
"requestFramePermissions": [
|
||||||
"geolocation"
|
"geolocation"
|
||||||
2924
frontend/package-lock.json
generated
Normal file
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "photummanager",
|
"name": "photumformaturas",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
@ -9,10 +9,13 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.2.0",
|
"@google/genai": "^1.30.0",
|
||||||
|
"@types/mapbox-gl": "^3.4.1",
|
||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.554.0",
|
||||||
|
"mapbox-gl": "^3.16.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"@google/genai": "^1.30.0"
|
"react-router-dom": "^7.9.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
583
frontend/pages/Calendar.tsx
Normal file
|
|
@ -0,0 +1,583 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Calendar, Clock, MapPin, User, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Event {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
time: string;
|
||||||
|
location: string;
|
||||||
|
client: string;
|
||||||
|
status: 'confirmed' | 'pending' | 'completed';
|
||||||
|
type: 'formatura' | 'casamento' | 'evento';
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_EVENTS: Event[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Formatura Medicina UFPR',
|
||||||
|
date: '2025-12-15',
|
||||||
|
time: '19:00',
|
||||||
|
location: 'Teatro Guaíra, Curitiba',
|
||||||
|
client: 'Ana Paula Silva',
|
||||||
|
status: 'confirmed',
|
||||||
|
type: 'formatura'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: 'Casamento Maria & João',
|
||||||
|
date: '2025-12-20',
|
||||||
|
time: '16:00',
|
||||||
|
location: 'Fazenda Vista Alegre',
|
||||||
|
client: 'Maria Santos',
|
||||||
|
status: 'confirmed',
|
||||||
|
type: 'casamento'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
title: 'Formatura Direito PUC',
|
||||||
|
date: '2025-12-22',
|
||||||
|
time: '20:00',
|
||||||
|
location: 'Centro de Convenções',
|
||||||
|
client: 'Carlos Eduardo',
|
||||||
|
status: 'pending',
|
||||||
|
type: 'formatura'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
title: 'Formatura Engenharia UTFPR',
|
||||||
|
date: '2025-12-28',
|
||||||
|
time: '18:30',
|
||||||
|
location: 'Espaço Nobre Eventos',
|
||||||
|
client: 'Roberto Mendes',
|
||||||
|
status: 'confirmed',
|
||||||
|
type: 'formatura'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
title: 'Evento Corporativo Tech Summit',
|
||||||
|
date: '2026-01-10',
|
||||||
|
time: '09:00',
|
||||||
|
location: 'Hotel Bourbon',
|
||||||
|
client: 'TechCorp Ltda',
|
||||||
|
status: 'pending',
|
||||||
|
type: 'evento'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '6',
|
||||||
|
title: 'Formatura Odontologia',
|
||||||
|
date: '2026-01-15',
|
||||||
|
time: '19:30',
|
||||||
|
location: 'Clube Curitibano',
|
||||||
|
client: 'Juliana Costa',
|
||||||
|
status: 'confirmed',
|
||||||
|
type: 'formatura'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CalendarPage: React.FC = () => {
|
||||||
|
const [selectedMonth, setSelectedMonth] = useState(new Date());
|
||||||
|
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
|
||||||
|
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||||
|
|
||||||
|
const getStatusColor = (status: Event['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'confirmed':
|
||||||
|
return 'bg-green-100 text-green-800';
|
||||||
|
case 'pending':
|
||||||
|
return 'bg-yellow-100 text-yellow-800';
|
||||||
|
case 'completed':
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status: Event['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'confirmed':
|
||||||
|
return 'Confirmado';
|
||||||
|
case 'pending':
|
||||||
|
return 'Pendente';
|
||||||
|
case 'completed':
|
||||||
|
return 'Concluído';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeColor = (type: Event['type']) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'formatura':
|
||||||
|
return 'bg-blue-100 text-blue-800';
|
||||||
|
case 'casamento':
|
||||||
|
return 'bg-pink-100 text-pink-800';
|
||||||
|
case 'evento':
|
||||||
|
return 'bg-purple-100 text-purple-800';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeLabel = (type: Event['type']) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'formatura':
|
||||||
|
return 'Formatura';
|
||||||
|
case 'casamento':
|
||||||
|
return 'Casamento';
|
||||||
|
case 'evento':
|
||||||
|
return 'Evento';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString + 'T00:00:00');
|
||||||
|
return date.toLocaleDateString('pt-BR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextMonth = () => {
|
||||||
|
setSelectedMonth(new Date(selectedMonth.getFullYear(), selectedMonth.getMonth() + 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevMonth = () => {
|
||||||
|
setSelectedMonth(new Date(selectedMonth.getFullYear(), selectedMonth.getMonth() - 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentMonthName = selectedMonth.toLocaleDateString('pt-BR', {
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate calendar days
|
||||||
|
const generateCalendarDays = () => {
|
||||||
|
const year = selectedMonth.getFullYear();
|
||||||
|
const month = selectedMonth.getMonth();
|
||||||
|
|
||||||
|
const firstDay = new Date(year, month, 1);
|
||||||
|
const lastDay = new Date(year, month + 1, 0);
|
||||||
|
|
||||||
|
const firstDayOfWeek = firstDay.getDay();
|
||||||
|
const daysInMonth = lastDay.getDate();
|
||||||
|
|
||||||
|
const days: (Date | null)[] = [];
|
||||||
|
|
||||||
|
// Add empty cells for days before month starts
|
||||||
|
for (let i = 0; i < firstDayOfWeek; i++) {
|
||||||
|
days.push(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add all days of the month
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
days.push(new Date(year, month, day));
|
||||||
|
}
|
||||||
|
|
||||||
|
return days;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEventsForDate = (date: Date) => {
|
||||||
|
return MOCK_EVENTS.filter(event => {
|
||||||
|
const eventDate = new Date(event.date + 'T00:00:00');
|
||||||
|
return eventDate.getDate() === date.getDate() &&
|
||||||
|
eventDate.getMonth() === date.getMonth() &&
|
||||||
|
eventDate.getFullYear() === date.getFullYear();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isToday = (date: Date) => {
|
||||||
|
const today = new Date();
|
||||||
|
return date.getDate() === today.getDate() &&
|
||||||
|
date.getMonth() === today.getMonth() &&
|
||||||
|
date.getFullYear() === today.getFullYear();
|
||||||
|
};
|
||||||
|
|
||||||
|
const calendarDays = generateCalendarDays();
|
||||||
|
|
||||||
|
// Filter events for selected month
|
||||||
|
const monthEvents = MOCK_EVENTS.filter(event => {
|
||||||
|
const eventDate = new Date(event.date + 'T00:00:00');
|
||||||
|
return eventDate.getMonth() === selectedMonth.getMonth() &&
|
||||||
|
eventDate.getFullYear() === selectedMonth.getFullYear();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort events by date
|
||||||
|
const sortedEvents = [...MOCK_EVENTS].sort((a, b) =>
|
||||||
|
new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 pt-20 sm:pt-24 md:pt-32 pb-8 sm:pb-12">
|
||||||
|
<div className="max-w-7xl mx-auto px-3 sm:px-4 md:px-6 lg:px-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6 sm:mb-8">
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-serif font-bold text-brand-black mb-1 sm:mb-2">
|
||||||
|
Minha Agenda
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm sm:text-base text-gray-600">
|
||||||
|
Gerencie seus eventos e compromissos fotográficos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||||
|
{/* Full Calendar Grid */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-3 sm:p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3 sm:mb-4">
|
||||||
|
<button
|
||||||
|
onClick={prevMonth}
|
||||||
|
className="p-1 sm:p-1.5 hover:bg-gray-100 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||||
|
</button>
|
||||||
|
<h2 className="text-base sm:text-lg font-bold capitalize" style={{ color: '#B9CF33' }}>
|
||||||
|
{currentMonthName}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={nextMonth}
|
||||||
|
className="p-1 sm:p-1.5 hover:bg-gray-100 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar Grid */}
|
||||||
|
<div className="mb-3 sm:mb-4">
|
||||||
|
{/* Week Days Header */}
|
||||||
|
<div className="grid grid-cols-7 gap-0.5 sm:gap-1 mb-1">
|
||||||
|
{['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb'].map((day) => (
|
||||||
|
<div
|
||||||
|
key={day}
|
||||||
|
className="text-center text-[10px] sm:text-xs font-semibold text-gray-600 py-1"
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar Days */}
|
||||||
|
<div className="grid grid-cols-7 gap-0.5 sm:gap-1">
|
||||||
|
{calendarDays.map((date, index) => {
|
||||||
|
if (!date) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`empty-${index}`}
|
||||||
|
className="h-10 sm:h-12 bg-gray-50 rounded"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayEvents = getEventsForDate(date);
|
||||||
|
const hasEvents = dayEvents.length > 0;
|
||||||
|
const today = isToday(date);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => setSelectedDate(date)}
|
||||||
|
className={`
|
||||||
|
h-10 sm:h-12 rounded p-0.5 sm:p-1 transition-all duration-200
|
||||||
|
${today ? 'bg-[#B9CF33] text-white font-bold' : 'hover:bg-gray-100 active:bg-gray-200'}
|
||||||
|
${hasEvents && !today ? 'bg-blue-50 border border-[#B9CF33] sm:border-2' : 'bg-white border border-gray-200'}
|
||||||
|
relative group
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center justify-center h-full">
|
||||||
|
<span className={`text-[10px] sm:text-xs ${today ? 'text-white' : 'text-gray-700'}`}>
|
||||||
|
{date.getDate()}
|
||||||
|
</span>
|
||||||
|
{hasEvents && (
|
||||||
|
<div className="flex gap-0.5 mt-0.5">
|
||||||
|
{dayEvents.slice(0, 3).map((event, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`w-0.5 h-0.5 sm:w-1 sm:h-1 rounded-full ${
|
||||||
|
event.status === 'confirmed' ? 'bg-green-500' :
|
||||||
|
event.status === 'pending' ? 'bg-yellow-500' :
|
||||||
|
'bg-gray-400'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tooltip on hover - hide on mobile */}
|
||||||
|
{hasEvents && (
|
||||||
|
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 hidden sm:group-hover:block z-10">
|
||||||
|
<div className="bg-gray-900 text-white text-xs rounded py-1 px-2 whitespace-nowrap">
|
||||||
|
{dayEvents.length} evento{dayEvents.length > 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 sm:mt-4 pt-3 sm:pt-4 border-t border-gray-200">
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<span className="text-gray-600 text-[10px] sm:text-xs">Eventos este mês:</span>
|
||||||
|
<span className="font-bold text-sm sm:text-base" style={{ color: '#B9CF33' }}>{monthEvents.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<span className="text-gray-600 text-[10px] sm:text-xs">Total:</span>
|
||||||
|
<span className="font-semibold text-sm sm:text-base">{MOCK_EVENTS.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend and Info Sidebar */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-3 sm:p-4 lg:sticky lg:top-24">
|
||||||
|
<h3 className="text-sm font-bold mb-2 sm:mb-3" style={{ color: '#492E61' }}>Legenda</h3>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-1 gap-2 mb-3 sm:mb-4">
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-green-500 flex-shrink-0"></div>
|
||||||
|
<span>Confirmado</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-yellow-500 flex-shrink-0"></div>
|
||||||
|
<span>Pendente</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-gray-500 flex-shrink-0"></div>
|
||||||
|
<span>Concluído</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<div className="w-3 h-3 rounded flex-shrink-0" style={{ backgroundColor: '#B9CF33' }}></div>
|
||||||
|
<span>Hoje</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs col-span-2 sm:col-span-1">
|
||||||
|
<div className="w-3 h-3 rounded bg-blue-50 border-2 flex-shrink-0" style={{ borderColor: '#B9CF33' }}></div>
|
||||||
|
<span>Com eventos</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedDate && (
|
||||||
|
<div className="pt-3 sm:pt-4 border-t border-gray-200">
|
||||||
|
<h3 className="text-xs font-bold mb-2" style={{ color: '#492E61' }}>
|
||||||
|
{selectedDate.toLocaleDateString('pt-BR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric'
|
||||||
|
})}
|
||||||
|
</h3>
|
||||||
|
{getEventsForDate(selectedDate).length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{getEventsForDate(selectedDate).map(event => (
|
||||||
|
<div
|
||||||
|
key={event.id}
|
||||||
|
className="p-2 bg-gray-50 rounded cursor-pointer hover:bg-gray-100 active:bg-gray-200 transition-colors"
|
||||||
|
onClick={() => setSelectedEvent(event)}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-xs text-gray-900 mb-1 line-clamp-1">
|
||||||
|
{event.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 flex items-center">
|
||||||
|
<Clock size={10} className="inline mr-1 flex-shrink-0" />
|
||||||
|
{event.time}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-500">Nenhum evento neste dia</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Events List */}
|
||||||
|
<div className="lg:col-span-3 mt-4 sm:mt-6">
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||||
|
<div className="p-4 sm:p-6 border-b border-gray-200">
|
||||||
|
<h2 className="text-lg sm:text-xl font-bold" style={{ color: '#492E61' }}>Próximos Eventos</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-gray-200">
|
||||||
|
{sortedEvents.length === 0 ? (
|
||||||
|
<div className="p-8 sm:p-12 text-center text-gray-500">
|
||||||
|
<Calendar className="w-10 h-10 sm:w-12 sm:h-12 mx-auto mb-3 sm:mb-4 text-gray-300" />
|
||||||
|
<p className="text-sm sm:text-base">Nenhum evento agendado</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
sortedEvents.map((event) => (
|
||||||
|
<div
|
||||||
|
key={event.id}
|
||||||
|
className="p-4 sm:p-6 hover:bg-gray-50 active:bg-gray-100 transition-colors cursor-pointer"
|
||||||
|
onClick={() => setSelectedEvent(event)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-2 sm:mb-3 gap-3 sm:gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start sm:items-center gap-2 mb-2 flex-col sm:flex-row">
|
||||||
|
<h3 className="text-base sm:text-lg font-semibold text-brand-black line-clamp-2">
|
||||||
|
{event.title}
|
||||||
|
</h3>
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getTypeColor(event.type)} whitespace-nowrap`}>
|
||||||
|
{getTypeLabel(event.type)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5 sm:gap-2">
|
||||||
|
<div className="flex items-center text-xs sm:text-sm text-gray-600">
|
||||||
|
<Calendar className="w-3 h-3 sm:w-4 sm:h-4 mr-1.5 sm:mr-2 flex-shrink-0" style={{ color: '#B9CF33' }} />
|
||||||
|
<span className="truncate">{formatDate(event.date)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center text-xs sm:text-sm text-gray-600">
|
||||||
|
<Clock className="w-3 h-3 sm:w-4 sm:h-4 mr-1.5 sm:mr-2 flex-shrink-0" style={{ color: '#B9CF33' }} />
|
||||||
|
<span>{event.time}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center text-xs sm:text-sm text-gray-600">
|
||||||
|
<MapPin className="w-3 h-3 sm:w-4 sm:h-4 mr-1.5 sm:mr-2 flex-shrink-0" style={{ color: '#B9CF33' }} />
|
||||||
|
<span className="truncate">{event.location}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center text-xs sm:text-sm text-gray-600">
|
||||||
|
<User className="w-3 h-3 sm:w-4 sm:h-4 mr-1.5 sm:mr-2 flex-shrink-0" style={{ color: '#B9CF33' }} />
|
||||||
|
<span className="truncate">{event.client}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`px-2 sm:px-3 py-1 rounded-full text-[10px] sm:text-xs font-medium whitespace-nowrap ${getStatusColor(event.status)} flex-shrink-0`}>
|
||||||
|
{getStatusLabel(event.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event Detail Modal - Improved & Centered */}
|
||||||
|
{selectedEvent && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fadeIn"
|
||||||
|
onClick={() => setSelectedEvent(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-white rounded-2xl max-w-lg w-full shadow-2xl transform transition-all duration-300 animate-slideUp overflow-hidden"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header with gradient */}
|
||||||
|
<div className="relative p-6 pb-8 bg-gradient-to-br from-[#B9CF33] to-[#a5bd2e]">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedEvent(null)}
|
||||||
|
className="absolute top-4 right-4 w-8 h-8 flex items-center justify-center rounded-full bg-white/20 hover:bg-white/30 text-white transition-colors"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="text-white">
|
||||||
|
<h2 className="text-xl sm:text-2xl font-bold mb-3 pr-8">
|
||||||
|
{selectedEvent.title}
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<span className="px-3 py-1 rounded-full text-xs font-medium bg-white/90 text-gray-800">
|
||||||
|
{getTypeLabel(selectedEvent.type)}
|
||||||
|
</span>
|
||||||
|
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(selectedEvent.status)}`}>
|
||||||
|
{getStatusLabel(selectedEvent.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
{/* Date */}
|
||||||
|
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-xl hover:bg-gray-100 transition-colors group">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-[#B9CF33]/10 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||||
|
<Calendar className="w-6 h-6" style={{ color: '#B9CF33' }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs text-gray-500 mb-0.5">Data</p>
|
||||||
|
<p className="font-semibold text-gray-900">{formatDate(selectedEvent.date)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time */}
|
||||||
|
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-xl hover:bg-gray-100 transition-colors group">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-[#B9CF33]/10 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||||
|
<Clock className="w-6 h-6" style={{ color: '#B9CF33' }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs text-gray-500 mb-0.5">Horário</p>
|
||||||
|
<p className="font-semibold text-gray-900">{selectedEvent.time}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
|
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-xl hover:bg-gray-100 transition-colors group">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-[#B9CF33]/10 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||||
|
<MapPin className="w-6 h-6" style={{ color: '#B9CF33' }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs text-gray-500 mb-0.5">Local</p>
|
||||||
|
<p className="font-semibold text-gray-900">{selectedEvent.location}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Client */}
|
||||||
|
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-xl hover:bg-gray-100 transition-colors group">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-[#B9CF33]/10 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||||
|
<User className="w-6 h-6" style={{ color: '#B9CF33' }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs text-gray-500 mb-0.5">Cliente</p>
|
||||||
|
<p className="font-semibold text-gray-900">{selectedEvent.client}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="p-6 pt-4 bg-gray-50 flex flex-col sm:flex-row gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedEvent(null)}
|
||||||
|
className="flex-1 px-6 py-3 bg-white border-2 border-gray-200 text-gray-700 rounded-xl hover:bg-gray-50 hover:border-gray-300 active:scale-95 transition-all font-medium"
|
||||||
|
>
|
||||||
|
Fechar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex-1 px-6 py-3 text-white rounded-xl transition-all font-medium shadow-lg hover:shadow-xl active:scale-95"
|
||||||
|
style={{ backgroundColor: '#B9CF33' }}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#a5bd2e'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#B9CF33'}
|
||||||
|
>
|
||||||
|
Ver Detalhes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px) scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fadeIn {
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slideUp {
|
||||||
|
animation: slideUp 0.3s ease-out;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
567
frontend/pages/Dashboard.tsx
Normal file
|
|
@ -0,0 +1,567 @@
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { UserRole, EventData, EventStatus, EventType } from "../types";
|
||||||
|
import { EventCard } from "../components/EventCard";
|
||||||
|
import { EventForm } from "../components/EventForm";
|
||||||
|
import { Button } from "../components/Button";
|
||||||
|
import {
|
||||||
|
PlusCircle,
|
||||||
|
Search,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
Edit,
|
||||||
|
Users,
|
||||||
|
Map,
|
||||||
|
Building2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import { useData } from "../contexts/DataContext";
|
||||||
|
import { STATUS_COLORS } from "../constants";
|
||||||
|
|
||||||
|
interface DashboardProps {
|
||||||
|
initialView?: "list" | "create";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
|
initialView = "list",
|
||||||
|
}) => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const {
|
||||||
|
events,
|
||||||
|
getEventsByRole,
|
||||||
|
addEvent,
|
||||||
|
updateEventStatus,
|
||||||
|
assignPhotographer,
|
||||||
|
getInstitutionById,
|
||||||
|
} = useData();
|
||||||
|
const [view, setView] = useState<"list" | "create" | "edit" | "details">(
|
||||||
|
initialView
|
||||||
|
);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [selectedEvent, setSelectedEvent] = useState<EventData | null>(null);
|
||||||
|
const [activeFilter, setActiveFilter] = useState<string>("all");
|
||||||
|
|
||||||
|
// Reset view when initialView prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialView) {
|
||||||
|
setView(initialView);
|
||||||
|
if (initialView === "create") setSelectedEvent(null);
|
||||||
|
}
|
||||||
|
}, [initialView]);
|
||||||
|
|
||||||
|
// Guard Clause for basic security
|
||||||
|
if (!user)
|
||||||
|
return <div className="p-10 text-center">Acesso Negado. Faça login.</div>;
|
||||||
|
|
||||||
|
const myEvents = getEventsByRole(user.id, user.role);
|
||||||
|
|
||||||
|
// Filter Logic
|
||||||
|
const filteredEvents = myEvents.filter((e) => {
|
||||||
|
const matchesSearch = e.name
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchTerm.toLowerCase());
|
||||||
|
const matchesStatus =
|
||||||
|
activeFilter === "all" ||
|
||||||
|
(activeFilter === "pending" &&
|
||||||
|
e.status === EventStatus.PENDING_APPROVAL) ||
|
||||||
|
(activeFilter === "active" &&
|
||||||
|
e.status !== EventStatus.ARCHIVED &&
|
||||||
|
e.status !== EventStatus.PENDING_APPROVAL);
|
||||||
|
return matchesSearch && matchesStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSaveEvent = (data: any) => {
|
||||||
|
const isClient = user.role === UserRole.EVENT_OWNER;
|
||||||
|
|
||||||
|
if (view === "edit" && selectedEvent) {
|
||||||
|
const updatedEvent = { ...selectedEvent, ...data };
|
||||||
|
console.log("Updated", updatedEvent);
|
||||||
|
setSelectedEvent(updatedEvent);
|
||||||
|
setView("details");
|
||||||
|
} else {
|
||||||
|
const initialStatus = isClient
|
||||||
|
? EventStatus.PENDING_APPROVAL
|
||||||
|
: EventStatus.PLANNING;
|
||||||
|
const newEvent: EventData = {
|
||||||
|
...data,
|
||||||
|
id: Math.random().toString(36).substr(2, 9),
|
||||||
|
status: initialStatus,
|
||||||
|
checklist: [],
|
||||||
|
ownerId: isClient ? user.id : "unknown",
|
||||||
|
photographerIds: [],
|
||||||
|
};
|
||||||
|
addEvent(newEvent);
|
||||||
|
setView("list");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApprove = (e: React.MouseEvent, eventId: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
updateEventStatus(eventId, EventStatus.CONFIRMED);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenMaps = () => {
|
||||||
|
if (!selectedEvent) return;
|
||||||
|
if (selectedEvent.address.mapLink) {
|
||||||
|
window.open(selectedEvent.address.mapLink, "_blank");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { street, number, city, state } = selectedEvent.address;
|
||||||
|
const query = encodeURIComponent(
|
||||||
|
`${street}, ${number}, ${city} - ${state}`
|
||||||
|
);
|
||||||
|
window.open(
|
||||||
|
`https://www.google.com/maps/search/?api=1&query=${query}`,
|
||||||
|
"_blank"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManageTeam = () => {
|
||||||
|
if (!selectedEvent) return;
|
||||||
|
const newId = window.prompt(
|
||||||
|
"ID do Fotógrafo para adicionar (ex: photographer-1):",
|
||||||
|
"photographer-1"
|
||||||
|
);
|
||||||
|
if (newId) {
|
||||||
|
assignPhotographer(selectedEvent.id, newId);
|
||||||
|
alert("Fotógrafo atribuído com sucesso!");
|
||||||
|
const updated = events.find((e) => e.id === selectedEvent.id);
|
||||||
|
if (updated) setSelectedEvent(updated);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- RENDERS PER ROLE ---
|
||||||
|
|
||||||
|
const renderRoleSpecificHeader = () => {
|
||||||
|
if (user.role === UserRole.EVENT_OWNER) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-serif font-bold text-brand-black">
|
||||||
|
Meus Eventos
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-1">
|
||||||
|
Acompanhe seus eventos ou solicite novos orçamentos.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (user.role === UserRole.PHOTOGRAPHER) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-serif font-bold text-brand-black">
|
||||||
|
Eventos Designados
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-1">
|
||||||
|
Gerencie seus trabalhos e visualize detalhes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-serif font-bold text-brand-black">
|
||||||
|
Gestão Geral
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-1">
|
||||||
|
Controle total de eventos, aprovações e equipes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRoleSpecificActions = () => {
|
||||||
|
if (user.role === UserRole.PHOTOGRAPHER) return null;
|
||||||
|
|
||||||
|
const label =
|
||||||
|
user.role === UserRole.EVENT_OWNER
|
||||||
|
? "Solicitar Novo Evento"
|
||||||
|
: "Novo Evento";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button onClick={() => setView("create")} className="shadow-lg">
|
||||||
|
<PlusCircle className="mr-2 h-5 w-5" /> {label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAdminActions = (event: EventData) => {
|
||||||
|
if (
|
||||||
|
user.role !== UserRole.BUSINESS_OWNER &&
|
||||||
|
user.role !== UserRole.SUPERADMIN
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (event.status === EventStatus.PENDING_APPROVAL) {
|
||||||
|
return (
|
||||||
|
<div className="absolute top-3 left-3 flex space-x-2 z-10">
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleApprove(e, event.id)}
|
||||||
|
className="bg-green-500 text-white px-3 py-1 rounded-sm text-xs font-bold shadow hover:bg-green-600 transition-colors flex items-center"
|
||||||
|
>
|
||||||
|
<CheckCircle size={12} className="mr-1" /> APROVAR
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- MAIN RENDER ---
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white pt-24 pb-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
{view === "list" && (
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between mb-8 gap-4 fade-in">
|
||||||
|
{renderRoleSpecificHeader()}
|
||||||
|
{renderRoleSpecificActions()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content Switcher */}
|
||||||
|
{view === "list" && (
|
||||||
|
<div className="space-y-6 fade-in">
|
||||||
|
{/* Filters Bar */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 items-center justify-between bg-gray-50 p-3 rounded-lg border border-gray-100">
|
||||||
|
<div className="relative flex-1 w-full">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar evento..."
|
||||||
|
className="w-full pl-10 pr-4 py-2 bg-white border border-gray-200 rounded-sm focus:outline-none focus:border-brand-gold text-sm"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(user.role === UserRole.BUSINESS_OWNER ||
|
||||||
|
user.role === UserRole.SUPERADMIN) && (
|
||||||
|
<div className="flex space-x-2 bg-white p-1 rounded border border-gray-200">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveFilter("all")}
|
||||||
|
className={`px-3 py-1 text-xs font-medium rounded-sm ${
|
||||||
|
activeFilter === "all"
|
||||||
|
? "bg-brand-black text-white"
|
||||||
|
: "text-gray-600 hover:bg-gray-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Todos
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveFilter("pending")}
|
||||||
|
className={`px-3 py-1 text-xs font-medium rounded-sm flex items-center ${
|
||||||
|
activeFilter === "pending"
|
||||||
|
? "bg-brand-gold text-white"
|
||||||
|
: "text-gray-600 hover:bg-gray-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Clock size={12} className="mr-1" /> Pendentes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{filteredEvents.map((event) => (
|
||||||
|
<div key={event.id} className="relative group">
|
||||||
|
{renderAdminActions(event)}
|
||||||
|
<EventCard
|
||||||
|
event={event}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedEvent(event);
|
||||||
|
setView("details");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredEvents.length === 0 && (
|
||||||
|
<div className="text-center py-20 bg-gray-50 rounded-lg border border-dashed border-gray-200">
|
||||||
|
<p className="text-gray-500 mb-4">
|
||||||
|
Nenhum evento encontrado com os filtros atuais.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(view === "create" || view === "edit") && (
|
||||||
|
<EventForm
|
||||||
|
onCancel={() => setView(view === "edit" ? "details" : "list")}
|
||||||
|
onSubmit={handleSaveEvent}
|
||||||
|
initialData={view === "edit" ? selectedEvent : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === "details" && selectedEvent && (
|
||||||
|
<div className="fade-in">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setView("list")}
|
||||||
|
className="mb-4 pl-0"
|
||||||
|
>
|
||||||
|
← Voltar para lista
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Status Banner */}
|
||||||
|
{selectedEvent.status === EventStatus.PENDING_APPROVAL &&
|
||||||
|
user.role === UserRole.EVENT_OWNER && (
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 text-yellow-800 p-4 rounded-lg mb-6 flex items-start">
|
||||||
|
<Clock className="mr-3 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold">Solicitação em Análise</h4>
|
||||||
|
<p className="text-sm mt-1">
|
||||||
|
Seu evento foi enviado e está aguardando aprovação da
|
||||||
|
equipe Photum.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white border rounded-lg overflow-hidden shadow-sm">
|
||||||
|
<div className="h-64 w-full relative">
|
||||||
|
<img
|
||||||
|
src={selectedEvent.coverImage}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
alt="Cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
||||||
|
<h1 className="text-4xl font-serif text-white font-bold text-center px-4 drop-shadow-lg">
|
||||||
|
{selectedEvent.name}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
<div className="col-span-2 space-y-8">
|
||||||
|
{/* Actions Toolbar */}
|
||||||
|
<div className="flex flex-wrap gap-3 border-b pb-4">
|
||||||
|
{(user.role === UserRole.BUSINESS_OWNER ||
|
||||||
|
user.role === UserRole.SUPERADMIN) && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setView("edit")}
|
||||||
|
>
|
||||||
|
<Edit size={16} className="mr-2" /> Editar Detalhes
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={handleManageTeam}>
|
||||||
|
<Users size={16} className="mr-2" /> Gerenciar
|
||||||
|
Equipe
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{user.role === UserRole.EVENT_OWNER &&
|
||||||
|
selectedEvent.status !== EventStatus.ARCHIVED && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setView("edit")}
|
||||||
|
>
|
||||||
|
<Edit size={16} className="mr-2" /> Editar
|
||||||
|
Informações
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Institution Information */}
|
||||||
|
{selectedEvent.institutionId &&
|
||||||
|
(() => {
|
||||||
|
const institution = getInstitutionById(
|
||||||
|
selectedEvent.institutionId
|
||||||
|
);
|
||||||
|
if (institution) {
|
||||||
|
return (
|
||||||
|
<section className="bg-gradient-to-br from-brand-gold/10 to-transparent border border-brand-gold/30 rounded-sm p-6">
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="bg-brand-gold/20 p-3 rounded-full">
|
||||||
|
<Building2
|
||||||
|
className="text-brand-gold"
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-lg font-bold text-brand-black mb-1">
|
||||||
|
{institution.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-brand-gold uppercase tracking-wide font-medium mb-3">
|
||||||
|
{institution.type}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500 text-xs uppercase tracking-wide">
|
||||||
|
Contato
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-700 font-medium">
|
||||||
|
{institution.phone}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{institution.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{institution.address && (
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500 text-xs uppercase tracking-wide">
|
||||||
|
Endereço
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-700">
|
||||||
|
{institution.address.street},{" "}
|
||||||
|
{institution.address.number}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{institution.address.city} -{" "}
|
||||||
|
{institution.address.state}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{institution.description && (
|
||||||
|
<p className="text-gray-600 text-sm mt-3 italic border-t border-brand-gold/20 pt-3">
|
||||||
|
{institution.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="text-lg font-bold border-b pb-2 mb-4 text-brand-black">
|
||||||
|
Sobre o Evento
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 leading-relaxed whitespace-pre-wrap">
|
||||||
|
{selectedEvent.briefing || "Sem briefing detalhado."}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{selectedEvent.contacts.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h3 className="text-lg font-bold border-b pb-2 mb-4 text-brand-black">
|
||||||
|
Contatos & Responsáveis
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
{selectedEvent.contacts.map((c, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="bg-gray-50 p-4 rounded-sm border border-gray-100"
|
||||||
|
>
|
||||||
|
<p className="font-bold text-sm">{c.name}</p>
|
||||||
|
<p className="text-xs text-brand-gold uppercase tracking-wide">
|
||||||
|
{c.role}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{c.phone}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-1 space-y-6">
|
||||||
|
<div
|
||||||
|
className={`p-6 rounded-sm border ${
|
||||||
|
STATUS_COLORS[selectedEvent.status]
|
||||||
|
} bg-opacity-10`}
|
||||||
|
>
|
||||||
|
<h4 className="font-bold uppercase tracking-widest text-xs mb-2 opacity-70">
|
||||||
|
Status Atual
|
||||||
|
</h4>
|
||||||
|
<p className="text-xl font-serif font-bold">
|
||||||
|
{selectedEvent.status}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border p-6 rounded-sm bg-gray-50">
|
||||||
|
<h4 className="font-bold uppercase tracking-widest text-xs mb-4 text-gray-400">
|
||||||
|
Localização
|
||||||
|
</h4>
|
||||||
|
<p className="font-medium text-lg">
|
||||||
|
{selectedEvent.address.street},{" "}
|
||||||
|
{selectedEvent.address.number}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-500 mb-4">
|
||||||
|
{selectedEvent.address.city} -{" "}
|
||||||
|
{selectedEvent.address.state}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{selectedEvent.address.mapLink ? (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleOpenMaps}
|
||||||
|
>
|
||||||
|
<Map size={16} className="mr-2" /> Abrir no Google
|
||||||
|
Maps
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full bg-white"
|
||||||
|
onClick={handleOpenMaps}
|
||||||
|
>
|
||||||
|
<Map size={16} className="mr-2" /> Buscar no Maps
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(selectedEvent.photographerIds.length > 0 ||
|
||||||
|
user.role === UserRole.BUSINESS_OWNER) && (
|
||||||
|
<div className="border p-6 rounded-sm">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h4 className="font-bold uppercase tracking-widest text-xs text-gray-400">
|
||||||
|
Equipe Designada
|
||||||
|
</h4>
|
||||||
|
{(user.role === UserRole.BUSINESS_OWNER ||
|
||||||
|
user.role === UserRole.SUPERADMIN) && (
|
||||||
|
<button
|
||||||
|
onClick={handleManageTeam}
|
||||||
|
className="text-brand-gold hover:text-brand-black"
|
||||||
|
>
|
||||||
|
<PlusCircle size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedEvent.photographerIds.length > 0 ? (
|
||||||
|
<div className="flex -space-x-2">
|
||||||
|
{selectedEvent.photographerIds.map((id, idx) => (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
className="w-10 h-10 rounded-full border-2 border-white bg-gray-300"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(https://i.pravatar.cc/100?u=${id})`,
|
||||||
|
backgroundSize: "cover",
|
||||||
|
}}
|
||||||
|
title={id}
|
||||||
|
></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-400 italic">
|
||||||
|
Nenhum profissional atribuído.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
371
frontend/pages/Finance.tsx
Normal file
|
|
@ -0,0 +1,371 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { DollarSign, TrendingUp, TrendingDown, Calendar, Download, Filter, CreditCard, CheckCircle, Clock, XCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Transaction {
|
||||||
|
id: string;
|
||||||
|
type: 'income' | 'expense';
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
date: string;
|
||||||
|
status: 'paid' | 'pending' | 'overdue';
|
||||||
|
client?: string;
|
||||||
|
event?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_TRANSACTIONS: Transaction[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
type: 'income',
|
||||||
|
category: 'Formatura',
|
||||||
|
description: 'Pagamento Formatura Medicina UFPR',
|
||||||
|
amount: 8500.00,
|
||||||
|
date: '2025-12-01',
|
||||||
|
status: 'paid',
|
||||||
|
client: 'Ana Paula Silva',
|
||||||
|
event: 'Formatura Medicina UFPR'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
type: 'income',
|
||||||
|
category: 'Casamento',
|
||||||
|
description: 'Sinal Casamento Maria & João',
|
||||||
|
amount: 3000.00,
|
||||||
|
date: '2025-12-05',
|
||||||
|
status: 'paid',
|
||||||
|
client: 'Maria Santos',
|
||||||
|
event: 'Casamento Maria & João'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
type: 'expense',
|
||||||
|
category: 'Equipamento',
|
||||||
|
description: 'Manutenção Câmera Canon',
|
||||||
|
amount: 450.00,
|
||||||
|
date: '2025-12-03',
|
||||||
|
status: 'paid'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
type: 'income',
|
||||||
|
category: 'Formatura',
|
||||||
|
description: 'Pagamento Formatura Direito PUC',
|
||||||
|
amount: 7200.00,
|
||||||
|
date: '2025-12-10',
|
||||||
|
status: 'pending',
|
||||||
|
client: 'Carlos Eduardo',
|
||||||
|
event: 'Formatura Direito PUC'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
type: 'expense',
|
||||||
|
category: 'Transporte',
|
||||||
|
description: 'Combustível - Eventos Dezembro',
|
||||||
|
amount: 320.00,
|
||||||
|
date: '2025-12-08',
|
||||||
|
status: 'paid'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '6',
|
||||||
|
type: 'income',
|
||||||
|
category: 'Evento Corporativo',
|
||||||
|
description: 'Tech Summit 2026',
|
||||||
|
amount: 5500.00,
|
||||||
|
date: '2025-12-15',
|
||||||
|
status: 'pending',
|
||||||
|
client: 'TechCorp Ltda',
|
||||||
|
event: 'Tech Summit'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '7',
|
||||||
|
type: 'expense',
|
||||||
|
category: 'Software',
|
||||||
|
description: 'Assinatura Adobe Creative Cloud',
|
||||||
|
amount: 180.00,
|
||||||
|
date: '2025-12-01',
|
||||||
|
status: 'paid'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '8',
|
||||||
|
type: 'income',
|
||||||
|
category: 'Formatura',
|
||||||
|
description: 'Saldo Final Formatura Engenharia',
|
||||||
|
amount: 4500.00,
|
||||||
|
date: '2025-11-20',
|
||||||
|
status: 'overdue',
|
||||||
|
client: 'Roberto Mendes',
|
||||||
|
event: 'Formatura Engenharia UTFPR'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const FinancePage: React.FC = () => {
|
||||||
|
const [filterType, setFilterType] = useState<'all' | 'income' | 'expense'>('all');
|
||||||
|
const [filterStatus, setFilterStatus] = useState<'all' | 'paid' | 'pending' | 'overdue'>('all');
|
||||||
|
|
||||||
|
const filteredTransactions = MOCK_TRANSACTIONS.filter(transaction => {
|
||||||
|
const matchesType = filterType === 'all' || transaction.type === filterType;
|
||||||
|
const matchesStatus = filterStatus === 'all' || transaction.status === filterStatus;
|
||||||
|
return matchesType && matchesStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalIncome = MOCK_TRANSACTIONS
|
||||||
|
.filter(t => t.type === 'income' && t.status === 'paid')
|
||||||
|
.reduce((sum, t) => sum + t.amount, 0);
|
||||||
|
|
||||||
|
const totalExpense = MOCK_TRANSACTIONS
|
||||||
|
.filter(t => t.type === 'expense' && t.status === 'paid')
|
||||||
|
.reduce((sum, t) => sum + t.amount, 0);
|
||||||
|
|
||||||
|
const pendingIncome = MOCK_TRANSACTIONS
|
||||||
|
.filter(t => t.type === 'income' && (t.status === 'pending' || t.status === 'overdue'))
|
||||||
|
.reduce((sum, t) => sum + t.amount, 0);
|
||||||
|
|
||||||
|
const balance = totalIncome - totalExpense;
|
||||||
|
|
||||||
|
const getStatusIcon = (status: Transaction['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'paid':
|
||||||
|
return <CheckCircle size={16} className="text-green-600" />;
|
||||||
|
case 'pending':
|
||||||
|
return <Clock size={16} className="text-yellow-600" />;
|
||||||
|
case 'overdue':
|
||||||
|
return <XCircle size={16} className="text-red-600" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: Transaction['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'paid':
|
||||||
|
return 'bg-green-100 text-green-800';
|
||||||
|
case 'pending':
|
||||||
|
return 'bg-yellow-100 text-yellow-800';
|
||||||
|
case 'overdue':
|
||||||
|
return 'bg-red-100 text-red-800';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status: Transaction['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'paid':
|
||||||
|
return 'Pago';
|
||||||
|
case 'pending':
|
||||||
|
return 'Pendente';
|
||||||
|
case 'overdue':
|
||||||
|
return 'Atrasado';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) => {
|
||||||
|
return new Intl.NumberFormat('pt-BR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'BRL'
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString + 'T00:00:00');
|
||||||
|
return date.toLocaleDateString('pt-BR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 pt-32 pb-12">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-serif font-bold text-brand-black mb-2">
|
||||||
|
Financeiro
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Acompanhe receitas, despesas e fluxo de caixa
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button className="flex items-center gap-2 px-4 py-2 bg-brand-gold text-white rounded-md hover:bg-[#a5bd2e] transition-colors font-medium">
|
||||||
|
<Download size={20} />
|
||||||
|
Exportar Relatório
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<p className="text-sm text-gray-600">Receitas</p>
|
||||||
|
<TrendingUp className="text-green-600" size={24} />
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-bold text-green-600">{formatCurrency(totalIncome)}</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Pagamentos recebidos</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<p className="text-sm text-gray-600">Despesas</p>
|
||||||
|
<TrendingDown className="text-red-600" size={24} />
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-bold text-red-600">{formatCurrency(totalExpense)}</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Gastos do período</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<p className="text-sm text-gray-600">Saldo</p>
|
||||||
|
<DollarSign className="text-brand-gold" size={24} />
|
||||||
|
</div>
|
||||||
|
<p className={`text-3xl font-bold ${balance >= 0 ? 'text-brand-gold' : 'text-red-600'}`}>
|
||||||
|
{formatCurrency(balance)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Receitas - Despesas</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<p className="text-sm text-gray-600">A Receber</p>
|
||||||
|
<Clock className="text-yellow-600" size={24} />
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-bold text-yellow-600">{formatCurrency(pendingIncome)}</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Pagamentos pendentes</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
|
||||||
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterType('all')}
|
||||||
|
className={`px-4 py-2 rounded-md font-medium transition-colors ${filterType === 'all'
|
||||||
|
? 'bg-brand-gold text-white'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Todas
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterType('income')}
|
||||||
|
className={`px-4 py-2 rounded-md font-medium transition-colors ${filterType === 'income'
|
||||||
|
? 'bg-green-600 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Receitas
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterType('expense')}
|
||||||
|
className={`px-4 py-2 rounded-md font-medium transition-colors ${filterType === 'expense'
|
||||||
|
? 'bg-red-600 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Despesas
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterStatus('all')}
|
||||||
|
className={`px-4 py-2 rounded-md font-medium transition-colors ${filterStatus === 'all'
|
||||||
|
? 'bg-brand-black text-white'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Todos Status
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterStatus('paid')}
|
||||||
|
className={`px-4 py-2 rounded-md font-medium transition-colors ${filterStatus === 'paid'
|
||||||
|
? 'bg-green-600 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Pagos
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterStatus('pending')}
|
||||||
|
className={`px-4 py-2 rounded-md font-medium transition-colors ${filterStatus === 'pending'
|
||||||
|
? 'bg-yellow-600 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Pendentes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transactions List */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||||
|
<div className="p-6 border-b border-gray-200">
|
||||||
|
<h2 className="text-xl font-semibold">Transações</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-gray-200">
|
||||||
|
{filteredTransactions.length === 0 ? (
|
||||||
|
<div className="p-12 text-center text-gray-500">
|
||||||
|
<CreditCard size={48} className="mx-auto mb-4 text-gray-300" />
|
||||||
|
<p>Nenhuma transação encontrada</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredTransactions.map((transaction) => (
|
||||||
|
<div
|
||||||
|
key={transaction.id}
|
||||||
|
className="p-6 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${transaction.type === 'income' ? 'bg-green-100' : 'bg-red-100'
|
||||||
|
}`}>
|
||||||
|
{transaction.type === 'income' ? (
|
||||||
|
<TrendingUp size={20} className="text-green-600" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown size={20} className="text-red-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-brand-black">
|
||||||
|
{transaction.description}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<span className="text-xs text-gray-500">{transaction.category}</span>
|
||||||
|
{transaction.client && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-gray-300">•</span>
|
||||||
|
<span className="text-xs text-gray-500">{transaction.client}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-right">
|
||||||
|
<p className={`text-lg font-bold ${transaction.type === 'income' ? 'text-green-600' : 'text-red-600'
|
||||||
|
}`}>
|
||||||
|
{transaction.type === 'income' ? '+' : '-'} {formatCurrency(transaction.amount)}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-gray-500 mt-1">
|
||||||
|
<Calendar size={12} />
|
||||||
|
{formatDate(transaction.date)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getStatusIcon(transaction.status)}
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(transaction.status)}`}>
|
||||||
|
{getStatusLabel(transaction.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
307
frontend/pages/Home.tsx
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Camera, Heart, Shield, Star, BookOpen } from 'lucide-react';
|
||||||
|
|
||||||
|
const HERO_IMAGES = [
|
||||||
|
"/banner2.jpg",
|
||||||
|
"/HOME_01.jpg"
|
||||||
|
];
|
||||||
|
|
||||||
|
interface HomeProps {
|
||||||
|
onEnter: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook para detectar quando elemento está visível
|
||||||
|
const useIntersectionObserver = (options = {}) => {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setIsVisible(true);
|
||||||
|
}
|
||||||
|
}, { threshold: 0.1, ...options });
|
||||||
|
|
||||||
|
if (ref.current) {
|
||||||
|
observer.observe(ref.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (ref.current) {
|
||||||
|
observer.unobserve(ref.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return [ref, isVisible] as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Home: React.FC<HomeProps> = ({ onEnter }) => {
|
||||||
|
const [currentSlide, setCurrentSlide] = useState(0);
|
||||||
|
const [albumsRef, albumsVisible] = useIntersectionObserver();
|
||||||
|
const [ctaRef, ctaVisible] = useIntersectionObserver();
|
||||||
|
const [testimonialsRef, testimonialsVisible] = useIntersectionObserver();
|
||||||
|
const [contactRef, contactVisible] = useIntersectionObserver();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setCurrentSlide((prev) => (prev + 1) % HERO_IMAGES.length);
|
||||||
|
}, 5000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white">
|
||||||
|
{/* Hero Section */}
|
||||||
|
<div className="relative h-[50vh] sm:h-[60vh] md:h-[70vh] w-full overflow-hidden">
|
||||||
|
{HERO_IMAGES.map((img, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`absolute inset-0 transition-opacity duration-1000 ease-in-out ${idx === currentSlide ? 'opacity-100' : 'opacity-0'}`}
|
||||||
|
>
|
||||||
|
<img src={img} alt="Hero" className="w-full h-full object-cover scale-110" />
|
||||||
|
<div className="absolute inset-0 bg-black/40"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Text - Only visible on first slide */}
|
||||||
|
<div className={`absolute inset-0 flex items-center justify-center text-center px-4 transition-opacity duration-500 ${currentSlide === 0 ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}>
|
||||||
|
<div className="max-w-4xl space-y-4 sm:space-y-6 slide-up">
|
||||||
|
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-serif text-white leading-tight">
|
||||||
|
Eternizando Momentos <br />
|
||||||
|
<span className="text-brand-gold italic">Únicos</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-base sm:text-lg md:text-xl text-gray-200 font-light max-w-2xl mx-auto tracking-wide">
|
||||||
|
Gestão completa para eventos inesquecíveis. Do planejamento à entrega do álbum perfeito.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Carousel Dots */}
|
||||||
|
<div className="absolute bottom-4 sm:bottom-10 left-0 right-0 flex justify-center space-x-2 sm:space-x-3">
|
||||||
|
{HERO_IMAGES.map((_, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
className={`w-2 h-2 rounded-full transition-all ${idx === currentSlide ? 'bg-brand-gold w-6 sm:w-8' : 'bg-white/50'}`}
|
||||||
|
onClick={() => setCurrentSlide(idx)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Albums Section */}
|
||||||
|
<section ref={albumsRef} className="py-12 sm:py-16 md:py-24 relative overflow-hidden" style={{ backgroundColor: '#ffffff' }}>
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex flex-col lg:flex-row items-center gap-8 sm:gap-12 lg:gap-16">
|
||||||
|
{/* Left side - Icon and Text */}
|
||||||
|
<div className="lg:w-1/2 text-center lg:text-left w-full">
|
||||||
|
<div className={`inline-flex items-center justify-center w-24 h-24 sm:w-32 sm:h-32 md:w-36 md:h-36 mb-6 sm:mb-8 transform transition-all duration-700 ease-out ${albumsVisible ? 'opacity-100 scale-100 rotate-0' : 'opacity-0 scale-75 -rotate-12'} hover:scale-110 hover:rotate-3`}>
|
||||||
|
<img src="/HOME_17.png" alt="Álbuns" className="w-full h-full object-contain drop-shadow-2xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className={`text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold mb-4 sm:mb-6 md:mb-8 leading-tight transition-all duration-700 ease-out delay-100 ${albumsVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10'}`} style={{color: '#B9CF33'}}>
|
||||||
|
ÁLBUNS<br/>PERSONALIZADOS
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className={`space-y-2 sm:space-y-3 text-gray-700 transition-all duration-700 ease-out delay-200 ${albumsVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10'}`}>
|
||||||
|
<p className="text-sm sm:text-base md:text-lg leading-relaxed">
|
||||||
|
Escolha a cor, tamanho, tecido, acabamento, modelo,
|
||||||
|
</p>
|
||||||
|
<p className="text-sm sm:text-base md:text-lg leading-relaxed">
|
||||||
|
laminação, tipo de impressão e muito mais!
|
||||||
|
</p>
|
||||||
|
<p className="text-base sm:text-lg md:text-xl font-semibold text-brand-black mt-3 sm:mt-4">
|
||||||
|
Tenha seu álbum exclusivo e de acordo com o seu gosto.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - CTA */}
|
||||||
|
<div ref={ctaRef} className={`w-full lg:w-2/8 flex flex-col items-center lg:items-start justify-center lg:ml-40 mt-8 lg:mt-0 transition-all duration-700 ease-out delay-300 ${ctaVisible ? 'opacity-100 translate-x-0 scale-100' : 'opacity-0 translate-x-20 scale-90'}`}>
|
||||||
|
<div className="bg-white rounded-lg shadow-2xl p-6 sm:p-8 md:p-10 border-t-4 transform transition-all duration-300 hover:shadow-xl hover:-translate-y-2 w-full max-w-sm" style={{ borderColor: '#B9CF33' }}>
|
||||||
|
<div className={`flex justify-center mb-4 sm:mb-6 transition-all duration-700 ease-out delay-400 ${ctaVisible ? 'opacity-100 scale-100' : 'opacity-0 scale-80'}`}>
|
||||||
|
<div className="w-38 h-38 sm:w-38 sm:h-38 md:w-50 md:h-50 transform transition-transform duration-300 hover:scale-110">
|
||||||
|
<img src="/logo.png" alt="Cadastrar" className="w-full h-full object-contain drop-shadow-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className={`text-gray-700 text-base sm:text-lg md:text-xl mb-4 sm:mb-5 text-center font-medium leading-relaxed transition-all duration-700 ease-out delay-500 ${ctaVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-5'}`}>
|
||||||
|
Faça parte da Photum e cadastre sua formatura.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onEnter}
|
||||||
|
className={`w-full px-6 sm:px-8 md:px-10 py-3 sm:py-4 md:py-5 text-white font-bold text-base sm:text-lg rounded-md transition-all duration-700 ease-out delay-600 transform hover:scale-105 hover:shadow-2xl active:scale-95 ${ctaVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-5'}`}
|
||||||
|
style={{ backgroundColor: '#B9CF33' }}
|
||||||
|
>
|
||||||
|
Cadastrar Formatura
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Decorative elements */}
|
||||||
|
<div className="hidden sm:block absolute top-10 right-10 w-32 h-32 rounded-full opacity-10" style={{ backgroundColor: '#B9CF33' }}></div>
|
||||||
|
<div className="hidden sm:block absolute bottom-10 left-10 w-24 h-24 rounded-full opacity-10" style={{ backgroundColor: '#C2388B' }}></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Contact Section */}
|
||||||
|
<section ref={contactRef} className="py-8 sm:py-12 md:py-16 relative overflow-hidden" style={{ backgroundColor: '#492E61' }}>
|
||||||
|
{/* Decorative gradient circles */}
|
||||||
|
<div className="hidden md:block absolute -top-24 -right-24 w-96 h-96 rounded-full opacity-10 blur-3xl" style={{ backgroundColor: '#B9CF33' }}></div>
|
||||||
|
<div className="hidden md:block absolute -bottom-24 -left-24 w-96 h-96 rounded-full opacity-10 blur-3xl" style={{ backgroundColor: '#C2388B' }}></div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 md:px-8 lg:px-12 xl:px-20 relative z-10">
|
||||||
|
<div className={`text-center mb-8 sm:mb-10 md:mb-12 transition-all duration-700 ${contactVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-10'}`}>
|
||||||
|
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#B9CF32] mb-2 sm:mb-3 tracking-wide">ENTRE EM CONTATO</h2>
|
||||||
|
<p className="text-sm sm:text-base text-white/90">Envie sua mensagem, ligue ou faça uma visita em nossa empresa!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6 items-start max-w-7xl mx-auto">
|
||||||
|
{/* Left side - Location Info */}
|
||||||
|
<div className={`space-y-2.5 sm:space-y-3 text-white transition-all duration-700 delay-200 ${contactVisible ? 'opacity-100 translate-x-0' : 'opacity-0 -translate-x-20'}`}>
|
||||||
|
<div className="group">
|
||||||
|
<div className="flex items-start gap-2 p-2 sm:p-2.5 rounded-lg transition-all duration-300 hover:bg-white/10 hover:shadow-lg cursor-pointer border border-white/5 hover:border-white/20">
|
||||||
|
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-white/20 flex items-center justify-center transition-transform duration-300 group-hover:scale-110">
|
||||||
|
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-bold mb-0.5 text-sm">Rua Bom Recreio, 305</p>
|
||||||
|
<p className="text-white/80 text-xs">Jd. Boer II • Americana SP</p>
|
||||||
|
<p className="text-white/80 text-xs mb-1">CEP 13477-720</p>
|
||||||
|
<a
|
||||||
|
href="https://www.google.com/maps/place/Photum+Formaturas/@-22.7442887,-47.290221,21z/data=!4m12!1m5!3m4!2zMjLCsDQ0JzM5LjMiUyA0N8KwMTcnMjQuOCJX!8m2!3d-22.744247!4d-47.290221!3m5!1s0x94c89755cd9e70a9:0x15496eb4ec405483!8m2!3d-22.7442757!4d-47.2902662!16s%2Fg%2F11g6my_mm1?hl=pt&entry=ttu&g_ep=EgoyMDI1MTEyMy4xIKXMDSoASAFQAw%3D%3D"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs underline hover:text-white transition-colors inline-flex items-center gap-1 font-medium"
|
||||||
|
>
|
||||||
|
Ver no mapa →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mini Map */}
|
||||||
|
<div className="rounded-lg overflow-hidden shadow-lg border border-white/10 h-32 sm:h-36">
|
||||||
|
<iframe
|
||||||
|
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d235.28736842715582!2d-47.29022099999999!3d-22.7442887!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x94c89755cd9e70a9%3A0x15496eb4ec405483!2sPhotum%20Formaturas!5e0!3m2!1spt-BR!2sbr!4v1701234567890!5m2!1spt-BR!2sbr"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
style={{ border: 0 }}
|
||||||
|
allowFullScreen
|
||||||
|
loading="lazy"
|
||||||
|
referrerPolicy="no-referrer-when-downgrade"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="group">
|
||||||
|
<div className="flex items-center gap-2 p-2 sm:p-2.5 rounded-lg transition-all duration-300 hover:bg-white/10 hover:shadow-lg cursor-pointer border border-white/5 hover:border-white/20">
|
||||||
|
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-white/20 flex items-center justify-center transition-transform duration-300 group-hover:scale-110">
|
||||||
|
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M20 15.5c-1.25 0-2.45-.2-3.57-.57-.35-.11-.74-.03-1.02.24l-2.2 2.2c-2.83-1.44-5.15-3.75-6.59-6.59l2.2-2.21c.28-.26.36-.65.25-1C8.7 6.45 8.5 5.25 8.5 4c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1 0 9.39 7.61 17 17 17 .55 0 1-.45 1-1v-3.5c0-.55-.45-1-1-1zM19 12h2c0-4.97-4.03-9-9-9v2c3.87 0 7 3.13 7 7zm-4 0h2c0-2.76-2.24-5-5-5v2c1.66 0 3 1.34 3 3z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-semibold text-sm">(19) 3405 5024</p>
|
||||||
|
<p className="font-semibold text-sm">(19) 3621 4621</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="group">
|
||||||
|
<div className="flex items-center gap-2 p-2 sm:p-2.5 rounded-lg transition-all duration-300 hover:bg-white/10 hover:shadow-lg cursor-pointer border border-white/5 hover:border-white/20">
|
||||||
|
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-white/20 flex items-center justify-center transition-transform duration-300 group-hover:scale-110">
|
||||||
|
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<a href="mailto:contato@photum.com.br" className="font-semibold hover:text-white transition-colors text-sm break-all">
|
||||||
|
contato@photum.com.br
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center - Contact Form */}
|
||||||
|
<div className={`space-y-2.5 bg-gradient-to-br from-white/10 to-white/5 p-4 sm:p-5 rounded-xl backdrop-blur-md border border-white/10 shadow-2xl transition-all duration-700 delay-250 ${contactVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10'}`}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Nome"
|
||||||
|
className="w-full px-3 py-2 text-sm bg-white/5 border-b-2 border-white/20 text-white placeholder-white/50 focus:border-white focus:bg-white/10 focus:outline-none transition-all duration-300 rounded-t-lg"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="E-mail"
|
||||||
|
className="w-full px-3 py-2 text-sm bg-white/5 border-b-2 border-white/20 text-white placeholder-white/50 focus:border-white focus:bg-white/10 focus:outline-none transition-all duration-300 rounded-t-lg"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
placeholder="Telefone"
|
||||||
|
className="w-full px-3 py-2 text-sm bg-white/5 border-b-2 border-white/20 text-white placeholder-white/50 focus:border-white focus:bg-white/10 focus:outline-none transition-all duration-300 rounded-t-lg"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
placeholder="Mensagem"
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 text-sm bg-white/5 border-b-2 border-white/20 text-white placeholder-white/50 focus:border-white focus:bg-white/10 focus:outline-none transition-all duration-300 resize-none rounded-t-lg"
|
||||||
|
></textarea>
|
||||||
|
<button
|
||||||
|
className="w-full px-6 py-2.5 text-sm text-white font-bold rounded-lg hover:opacity-90 transition-all duration-300 transform hover:scale-105 hover:shadow-xl active:scale-95 relative overflow-hidden group flex items-center justify-center gap-2"
|
||||||
|
style={{ backgroundColor: '#B9CF32' }}
|
||||||
|
>
|
||||||
|
<span className="relative z-10">Enviar</span>
|
||||||
|
<svg className="w-4 h-4 relative z-10 rotate-45" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||||
|
</svg>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700"></div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - WhatsApp Card */}
|
||||||
|
<div className={`transition-all duration-700 delay-300 ${contactVisible ? 'opacity-100 translate-x-0' : 'opacity-0 translate-x-20'}`}>
|
||||||
|
<div className="bg-gradient-to-br from-[#25D366]/20 to-[#128C7E]/20 p-5 sm:p-6 rounded-xl backdrop-blur-md border border-[#25D366]/30 shadow-2xl hover:shadow-[#25D366]/20 hover:-translate-y-1 transition-all duration-300">
|
||||||
|
<div className="flex flex-col items-center text-center space-y-3">
|
||||||
|
{/* WhatsApp Icon */}
|
||||||
|
<div className="w-16 h-16 sm:w-20 sm:h-20 rounded-full bg-[#25D366] flex items-center justify-center shadow-lg transform hover:scale-110 transition-transform duration-300">
|
||||||
|
<svg className="w-10 h-10 sm:w-12 sm:h-12 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-lg sm:text-xl font-bold text-white">Tire suas dúvidas</h3>
|
||||||
|
<p className="text-xs sm:text-sm text-white/90 leading-snug">
|
||||||
|
Faça orçamento direto no nosso WhatsApp
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* WhatsApp Button */}
|
||||||
|
<a
|
||||||
|
href="https://wa.me/5519999999999"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="w-full bg-[#25D366] hover:bg-[#128C7E] text-white font-bold py-2.5 px-4 rounded-lg transition-all duration-300 transform hover:scale-105 hover:shadow-xl active:scale-95 flex items-center justify-center gap-2 group text-sm"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 transform group-hover:rotate-12 transition-transform duration-300" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
|
||||||
|
</svg>
|
||||||
|
<span>Falar no WhatsApp</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* Additional Info */}
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-white/80">
|
||||||
|
<div className="w-2 h-2 bg-[#25D366] rounded-full animate-pulse"></div>
|
||||||
|
<span>Resposta rápida garantida</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
206
frontend/pages/Inspiration.tsx
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { Heart, Search, Filter } from "lucide-react";
|
||||||
|
import { Construction } from "lucide-react";
|
||||||
|
|
||||||
|
const MOCK_GALLERIES = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: "Formatura Medicina UNICAMP 2024",
|
||||||
|
category: "Medicina",
|
||||||
|
images: [
|
||||||
|
"https://images.unsplash.com/photo-1523050854058-8df90110c9f1?w=800",
|
||||||
|
"https://images.unsplash.com/photo-1541339907198-e08756dedf3f?w=800",
|
||||||
|
"https://images.unsplash.com/photo-1523240795612-9a054b0db644?w=800",
|
||||||
|
],
|
||||||
|
likes: 234,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: "Engenharia Civil - USP 2024",
|
||||||
|
category: "Engenharia",
|
||||||
|
images: [
|
||||||
|
"https://images.unsplash.com/photo-1513542789411-b6a5d4f31634?w=800",
|
||||||
|
"https://images.unsplash.com/photo-1511632765486-a01980e01a18?w=800",
|
||||||
|
"https://images.unsplash.com/photo-1523050854058-8df90110c9f1?w=800",
|
||||||
|
],
|
||||||
|
likes: 189,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: "Direito PUC 2023",
|
||||||
|
category: "Direito",
|
||||||
|
images: [
|
||||||
|
"https://images.unsplash.com/photo-1519389950473-47ba0277781c?w=800",
|
||||||
|
"https://images.unsplash.com/photo-1521737711867-e3b97375f902?w=800",
|
||||||
|
"https://images.unsplash.com/photo-1522202176988-66273c2fd55f?w=800",
|
||||||
|
],
|
||||||
|
likes: 312,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: "Arquitetura UNESP 2024",
|
||||||
|
category: "Arquitetura",
|
||||||
|
images: [
|
||||||
|
"https://images.unsplash.com/photo-1528605248644-14dd04022da1?w=800",
|
||||||
|
"https://images.unsplash.com/photo-1523050854058-8df90110c9f1?w=800",
|
||||||
|
"https://images.unsplash.com/photo-1519337265831-281ec6cc8514?w=800",
|
||||||
|
],
|
||||||
|
likes: 278,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const CATEGORIES = [
|
||||||
|
"Todas",
|
||||||
|
"Medicina",
|
||||||
|
"Engenharia",
|
||||||
|
"Direito",
|
||||||
|
"Arquitetura",
|
||||||
|
"Administração",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const InspirationPage: React.FC = () => {
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState("Todas");
|
||||||
|
|
||||||
|
const filteredGalleries = MOCK_GALLERIES.filter((gallery) => {
|
||||||
|
const matchesSearch = gallery.title
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchTerm.toLowerCase());
|
||||||
|
const matchesCategory =
|
||||||
|
selectedCategory === "Todas" || gallery.category === selectedCategory;
|
||||||
|
return matchesSearch && matchesCategory;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 pt-24 pb-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-12 fade-in">
|
||||||
|
<h1 className="text-4xl sm:text-5xl font-serif font-bold text-brand-black mb-4">
|
||||||
|
Galeria de Inspiração
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 text-lg max-w-2xl mx-auto">
|
||||||
|
Explore álbuns de formaturas anteriores e inspire-se para criar o
|
||||||
|
seu evento perfeito
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search and Filter */}
|
||||||
|
<div className="mb-8 space-y-4">
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="relative max-w-2xl mx-auto">
|
||||||
|
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-gray-400 h-5 w-5" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por curso, universidade..."
|
||||||
|
className="w-full pl-12 pr-4 py-3 bg-white border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-gold focus:border-transparent text-sm shadow-sm"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Filter */}
|
||||||
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
|
{CATEGORIES.map((category) => (
|
||||||
|
<button
|
||||||
|
key={category}
|
||||||
|
onClick={() => setSelectedCategory(category)}
|
||||||
|
className={`px-4 py-2 rounded-full text-sm font-medium transition-all ${
|
||||||
|
selectedCategory === category
|
||||||
|
? "bg-brand-gold text-white shadow-lg scale-105"
|
||||||
|
: "bg-white text-gray-700 hover:bg-gray-50 border border-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gallery Grid */}
|
||||||
|
{filteredGalleries.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{filteredGalleries.map((gallery) => (
|
||||||
|
<div
|
||||||
|
key={gallery.id}
|
||||||
|
className="bg-white rounded-xl overflow-hidden shadow-md hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2 group"
|
||||||
|
>
|
||||||
|
{/* Main Image */}
|
||||||
|
<div className="relative h-64 overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={gallery.images[0]}
|
||||||
|
alt={gallery.title}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||||
|
|
||||||
|
{/* Category Badge */}
|
||||||
|
<div className="absolute top-3 left-3 bg-white/95 backdrop-blur-sm px-3 py-1 rounded-full">
|
||||||
|
<span className="text-xs font-semibold text-brand-gold">
|
||||||
|
{gallery.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-5">
|
||||||
|
<h3 className="text-lg font-bold text-brand-black mb-2 line-clamp-1">
|
||||||
|
{gallery.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Thumbnail Preview */}
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
{gallery.images.slice(1, 3).map((img, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="w-16 h-16 rounded-lg overflow-hidden border border-gray-200"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={img}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="w-16 h-16 rounded-lg bg-gray-100 flex items-center justify-center text-xs text-gray-500 font-medium border border-gray-200">
|
||||||
|
+12
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between pt-3 border-t border-gray-100">
|
||||||
|
<div className="flex items-center gap-2 text-gray-500">
|
||||||
|
<Heart size={16} className="text-red-400" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{gallery.likes} curtidas
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button className="text-sm text-brand-gold font-semibold hover:underline">
|
||||||
|
Ver álbum →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<p className="text-gray-500 text-lg">Nenhuma galeria encontrada</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Coming Soon Banner */}
|
||||||
|
<div className="mt-16 bg-gradient-to-r from-brand-purple to-brand-gold/20 rounded-2xl p-8 text-center">
|
||||||
|
<Construction className="mx-auto text-white mb-4" size={48} />
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-2">
|
||||||
|
Em Breve: Mais Funcionalidades
|
||||||
|
</h2>
|
||||||
|
<p className="text-white/90 max-w-2xl mx-auto">
|
||||||
|
Estamos trabalhando para trazer mais galerias, filtros avançados e a
|
||||||
|
possibilidade de salvar seus favoritos!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
269
frontend/pages/LGPD.tsx
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
interface LGPDProps {
|
||||||
|
onNavigate: (page: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Section {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
content: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LGPD: React.FC<LGPDProps> = ({ onNavigate }) => {
|
||||||
|
const [openSection, setOpenSection] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const sections: Section[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Compromisso com a LGPD',
|
||||||
|
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>',
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
A Photum Formaturas está comprometida em proteger seus dados pessoais e cumprir todas as
|
||||||
|
disposições da Lei Geral de Proteção de Dados (Lei nº 13.709/2018). Esta página explica
|
||||||
|
como tratamos seus dados em conformidade com a LGPD.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'Princípios da LGPD',
|
||||||
|
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" /></svg>',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<p className="mb-4">Nosso tratamento de dados segue os seguintes princípios:</p>
|
||||||
|
<ul className="list-none space-y-2">
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> <strong>Finalidade:</strong> Tratamento para propósitos específicos e legítimos</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> <strong>Adequação:</strong> Compatível com as finalidades informadas</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> <strong>Necessidade:</strong> Limitado ao mínimo necessário</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> <strong>Transparência:</strong> Informações claras e acessíveis</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> <strong>Segurança:</strong> Medidas técnicas e administrativas adequadas</li>
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: 'Seus Direitos como Titular',
|
||||||
|
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" /></svg>',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<p className="mb-4">A LGPD garante diversos direitos sobre seus dados pessoais:</p>
|
||||||
|
<ul className="list-none space-y-2">
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Confirmação da existência de tratamento</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Acesso aos dados</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Correção de dados incompletos ou desatualizados</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Anonimização, bloqueio ou eliminação de dados desnecessários</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Portabilidade dos dados a outro fornecedor</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Eliminação dos dados tratados com seu consentimento</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Informação sobre o compartilhamento de dados</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Revogação do consentimento</li>
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: 'Bases Legais para Tratamento',
|
||||||
|
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" /></svg>',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<p className="mb-4">Tratamos seus dados com base nas seguintes hipóteses legais:</p>
|
||||||
|
<ul className="list-none space-y-2">
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Consentimento do titular</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Cumprimento de obrigação legal ou regulatória</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Execução de contrato ou procedimentos preliminares</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Exercício regular de direitos</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Legítimos interesses do controlador</li>
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
title: 'Compartilhamento de Dados',
|
||||||
|
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg>',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<p className="mb-4">Seus dados podem ser compartilhados com:</p>
|
||||||
|
<ul className="list-none space-y-2">
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Prestadores de serviços de pagamento</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Provedores de infraestrutura tecnológica</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Autoridades governamentais (quando exigido por lei)</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Parceiros comerciais (com seu consentimento)</li>
|
||||||
|
</ul>
|
||||||
|
<p className="mt-4">
|
||||||
|
Todos os terceiros são obrigados a manter a confidencialidade e segurança de seus dados.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
title: 'Segurança dos Dados',
|
||||||
|
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<p className="mb-4">Implementamos medidas de segurança técnicas e organizacionais:</p>
|
||||||
|
<ul className="list-none space-y-2">
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Criptografia de dados sensíveis</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Controles de acesso rigorosos</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Monitoramento contínuo de segurança</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Treinamento regular da equipe</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Plano de resposta a incidentes</li>
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
title: 'Retenção de Dados',
|
||||||
|
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>',
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
Mantemos seus dados pessoais apenas pelo tempo necessário para cumprir as finalidades para
|
||||||
|
as quais foram coletados, incluindo requisitos legais, contratuais, de resolução de disputas
|
||||||
|
e aplicação de nossos acordos. Após esse período, os dados são eliminados ou anonimizados de
|
||||||
|
forma segura.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
title: 'Encarregado de Proteção de Dados (DPO)',
|
||||||
|
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<p className="mb-4">
|
||||||
|
Nosso Encarregado de Proteção de Dados está disponível para esclarecer dúvidas e receber
|
||||||
|
solicitações relacionadas aos seus direitos:
|
||||||
|
</p>
|
||||||
|
<div className="bg-white p-4 rounded-lg border-l-4 mt-4" style={{borderColor: '#B9CF33'}}>
|
||||||
|
<p className="font-semibold text-gray-800 mb-2">Contato do DPO:</p>
|
||||||
|
<p className="text-gray-700">
|
||||||
|
<a href="mailto:lgpd@photum.com.br" className="font-semibold hover:opacity-80 transition-opacity" style={{color: '#B9CF33'}}>
|
||||||
|
lgpd@photum.com.br
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
title: 'Autoridade Nacional de Proteção de Dados (ANPD)',
|
||||||
|
icon: '<svg className="w-12 h-12" fill="none" stroke="#492E61" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<p className="mb-4">
|
||||||
|
Você tem o direito de apresentar reclamação à Autoridade Nacional de Proteção de Dados:
|
||||||
|
</p>
|
||||||
|
<div className="bg-white p-4 rounded-lg border-l-4" style={{borderColor: '#492E61'}}>
|
||||||
|
<p className="font-semibold text-gray-800 mb-2">ANPD:</p>
|
||||||
|
<p className="text-gray-700">
|
||||||
|
Website:{' '}
|
||||||
|
<a href="https://www.gov.br/anpd" target="_blank" rel="noopener noreferrer" className="hover:opacity-80 transition-opacity" style={{color: '#492E61'}}>
|
||||||
|
www.gov.br/anpd
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-white py-12 sm:py-20 px-4 sm:px-8">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate('home')}
|
||||||
|
className="mb-8 flex items-center gap-2 text-gray-600 hover:text-[#492E61] transition-all duration-300 group"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 transform group-hover:-translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Voltar para Home
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="text-center mb-12 fade-in">
|
||||||
|
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold mb-4" style={{ color: '#B9CF33' }}>
|
||||||
|
Lei Geral de Proteção de Dados
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 text-lg">Transparência e segurança no tratamento dos seus dados</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:gap-6">
|
||||||
|
{sections.map((section, index) => (
|
||||||
|
<div
|
||||||
|
key={section.id}
|
||||||
|
className="bg-white rounded-2xl shadow-lg overflow-hidden transform transition-all duration-500 hover:shadow-2xl"
|
||||||
|
style={{
|
||||||
|
animation: `fadeInUp 0.6s ease-out ${index * 0.1}s both`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpenSection(openSection === section.id ? null : section.id)}
|
||||||
|
className="w-full px-6 sm:px-8 py-6 flex items-center justify-between text-left bg-gradient-to-r from-white to-gray-50 hover:from-gray-50 hover:to-gray-100 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4 flex-1">
|
||||||
|
<div className="transform transition-transform duration-300 hover:scale-110" dangerouslySetInnerHTML={{ __html: section.icon }} />
|
||||||
|
<h2 className="text-xl sm:text-2xl font-bold text-gray-800">
|
||||||
|
{section.title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
className={`w-6 h-6 transition-transform duration-300 ${
|
||||||
|
openSection === section.id ? 'rotate-180' : ''
|
||||||
|
}`}
|
||||||
|
style={{ color: '#492E61' }}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`overflow-hidden transition-all duration-500 ${
|
||||||
|
openSection === section.id ? 'max-h-[1000px] opacity-100' : 'max-h-0 opacity-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="px-6 sm:px-8 py-6 bg-gradient-to-br from-gray-50 to-white border-t border-gray-100">
|
||||||
|
<div className="text-gray-700 leading-relaxed">
|
||||||
|
{section.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 text-center p-6 bg-white rounded-2xl shadow-lg">
|
||||||
|
<p className="text-gray-600">
|
||||||
|
<span className="font-semibold" style={{ color: '#492E61' }}>Última atualização:</span> Janeiro de 2025
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeInUp 0.8s ease-out;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
160
frontend/pages/Login.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { Button } from '../components/Button';
|
||||||
|
import { Input } from '../components/Input';
|
||||||
|
import { UserRole } from '../types';
|
||||||
|
|
||||||
|
interface LoginProps {
|
||||||
|
onNavigate?: (page: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Login: React.FC<LoginProps> = ({ onNavigate }) => {
|
||||||
|
const { login, availableUsers } = useAuth();
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
const success = await login(email);
|
||||||
|
if (!success) {
|
||||||
|
setError('Usuário não encontrado. Tente um dos e-mails de demonstração.');
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fillCredentials = (userEmail: string) => {
|
||||||
|
setEmail(userEmail);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRoleLabel = (role: UserRole) => {
|
||||||
|
switch(role) {
|
||||||
|
case UserRole.SUPERADMIN: return "Superadmin";
|
||||||
|
case UserRole.BUSINESS_OWNER: return "Empresa";
|
||||||
|
case UserRole.PHOTOGRAPHER: return "Fotógrafo";
|
||||||
|
case UserRole.EVENT_OWNER: return "Cliente";
|
||||||
|
default: return role;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col lg:flex-row bg-white">
|
||||||
|
{/* Left Side - Image */}
|
||||||
|
<div className="hidden lg:flex lg:w-1/2 relative overflow-hidden">
|
||||||
|
<img
|
||||||
|
src="https://images.unsplash.com/photo-1519741497674-611481863552?ixlib=rb-1.2.1&auto=format&fit=crop&w=1920&q=80"
|
||||||
|
alt="Photum Login"
|
||||||
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-[#492E61]/90 to-[#492E61]/70 flex items-center justify-center">
|
||||||
|
<div className="text-center text-white p-12">
|
||||||
|
<h1 className="text-5xl font-serif font-bold mb-4">Photum Formaturas</h1>
|
||||||
|
<p className="text-xl font-light tracking-wide max-w-md mx-auto">Gestão de eventos premium para quem não abre mão da excelência.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Side - Form */}
|
||||||
|
<div className="w-full lg:w-1/2 flex items-center justify-center p-4 sm:p-6 md:p-8 lg:p-16">
|
||||||
|
<div className="max-w-md w-full space-y-6 sm:space-y-8 fade-in">
|
||||||
|
<div className="text-center lg:text-left">
|
||||||
|
<span className="font-bold tracking-widest uppercase text-xs sm:text-sm" style={{color: '#B9CF33'}}>Bem-vindo de volta</span>
|
||||||
|
<h2 className="mt-2 text-2xl sm:text-3xl font-serif font-bold text-gray-900">Acesse sua conta</h2>
|
||||||
|
<p className="mt-2 text-xs sm:text-sm text-gray-600">
|
||||||
|
Não tem uma conta?{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onNavigate?.('register')}
|
||||||
|
className="font-medium hover:opacity-80 transition-opacity"
|
||||||
|
style={{color: '#B9CF33'}}
|
||||||
|
>
|
||||||
|
Cadastre-se
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="mt-6 sm:mt-8 space-y-4 sm:space-y-6" onSubmit={handleLogin}>
|
||||||
|
<div className="space-y-3 sm:space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-1.5 sm:mb-2">
|
||||||
|
E-MAIL CORPORATIVO OU PESSOAL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
placeholder="nome@exemplo.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full px-3 sm:px-4 py-2.5 sm:py-3 text-sm sm:text-base border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:border-transparent transition-all"
|
||||||
|
style={{focusRing: '2px solid #B9CF33'}}
|
||||||
|
onFocus={(e) => e.target.style.borderColor = '#B9CF33'}
|
||||||
|
onBlur={(e) => e.target.style.borderColor = '#d1d5db'}
|
||||||
|
/>
|
||||||
|
{error && <span className="text-xs text-red-500 mt-1 block">{error}</span>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-1.5 sm:mb-2">
|
||||||
|
SENHA
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
readOnly
|
||||||
|
className="w-full px-3 sm:px-4 py-2.5 sm:py-3 text-sm sm:text-base border border-gray-300 rounded-lg bg-gray-50 cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
<button type="button" className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400">
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full px-6 sm:px-10 py-3 sm:py-4 text-white font-bold text-base sm:text-lg rounded-lg transition-all duration-300 transform hover:scale-[1.02] hover:shadow-xl active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
style={{backgroundColor: '#4E345F'}}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Entrando...' : 'Entrar no Sistema'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Demo Users Quick Select */}
|
||||||
|
<div className="mt-6 sm:mt-10 pt-6 sm:pt-10 border-t border-gray-200">
|
||||||
|
<p className="text-[10px] sm:text-xs uppercase tracking-widest mb-3 sm:mb-4 text-center text-gray-400">Usuários de Demonstração (Clique para preencher)</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 sm:gap-3">
|
||||||
|
{availableUsers.map(user => (
|
||||||
|
<button
|
||||||
|
key={user.id}
|
||||||
|
onClick={() => fillCredentials(user.email)}
|
||||||
|
className="flex flex-col items-start p-3 sm:p-4 border-2 rounded-lg hover:bg-gray-50 transition-all duration-300 text-left group transform hover:scale-[1.02]"
|
||||||
|
style={{borderColor: '#e5e7eb'}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = '#B9CF33';
|
||||||
|
e.currentTarget.style.boxShadow = '0 4px 12px rgba(185, 207, 51, 0.15)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||||
|
e.currentTarget.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-xs sm:text-sm font-bold text-gray-900">{user.name}</span>
|
||||||
|
<span className="text-[10px] sm:text-xs uppercase tracking-wide mt-0.5 sm:mt-1 font-semibold" style={{color: '#B9CF33'}}>{getRoleLabel(user.role)}</span>
|
||||||
|
<span className="text-[10px] sm:text-xs text-gray-500 mt-0.5 sm:mt-1 truncate w-full">{user.email}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
201
frontend/pages/PrivacyPolicy.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
interface PrivacyPolicyProps {
|
||||||
|
onNavigate: (page: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Section {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
content: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PrivacyPolicy: React.FC<PrivacyPolicyProps> = ({ onNavigate }) => {
|
||||||
|
const [openSection, setOpenSection] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const sections: Section[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Informações que Coletamos',
|
||||||
|
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<p className="mb-4">
|
||||||
|
A Photum Formaturas coleta informações que você nos fornece diretamente ao criar uma conta,
|
||||||
|
solicitar serviços ou entrar em contato conosco. Isso pode incluir:
|
||||||
|
</p>
|
||||||
|
<ul className="list-none space-y-2">
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Nome completo</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Endereço de e-mail</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Número de telefone</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Informações sobre o evento (formatura)</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Dados de pagamento (processados de forma segura)</li>
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'Como Usamos suas Informações',
|
||||||
|
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<p className="mb-4">Utilizamos as informações coletadas para:</p>
|
||||||
|
<ul className="list-none space-y-2">
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Fornecer e gerenciar nossos serviços de fotografia</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Processar pagamentos e enviar confirmações</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Comunicar sobre seus eventos e serviços contratados</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Melhorar nossos serviços e experiência do cliente</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Enviar atualizações e informações relevantes</li>
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: 'Proteção de Dados',
|
||||||
|
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>',
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
Implementamos medidas de segurança técnicas e organizacionais para proteger suas informações
|
||||||
|
pessoais contra acesso não autorizado, alteração, divulgação ou destruição.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: 'Compartilhamento de Informações',
|
||||||
|
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" /></svg>',
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
Não vendemos, alugamos ou compartilhamos suas informações pessoais com terceiros, exceto
|
||||||
|
quando necessário para fornecer nossos serviços ou quando exigido por lei.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
title: 'Seus Direitos',
|
||||||
|
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" /></svg>',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<p className="mb-4">Você tem o direito de:</p>
|
||||||
|
<ul className="list-none space-y-2">
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Acessar suas informações pessoais</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Corrigir dados incorretos</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Solicitar a exclusão de seus dados</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Retirar seu consentimento a qualquer momento</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Solicitar a portabilidade de seus dados</li>
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
title: 'Contato',
|
||||||
|
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>',
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
Para questões sobre esta Política de Privacidade, entre em contato conosco em:{' '}
|
||||||
|
<a href="mailto:contato@photum.com.br" className="font-semibold hover:opacity-80 transition-opacity" style={{color: '#B9CF33'}}>
|
||||||
|
contato@photum.com.br
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-white py-12 sm:py-20 px-4 sm:px-8">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate('home')}
|
||||||
|
className="mb-8 flex items-center gap-2 text-gray-600 hover:text-[#492E61] transition-all duration-300 group"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 transform group-hover:-translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Voltar para Home
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="text-center mb-12 fade-in">
|
||||||
|
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold mb-4" style={{ color: '#B9CF33' }}>
|
||||||
|
Política de Privacidade
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 text-lg">Sua privacidade é nossa prioridade</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:gap-6">
|
||||||
|
{sections.map((section, index) => (
|
||||||
|
<div
|
||||||
|
key={section.id}
|
||||||
|
className="bg-white rounded-2xl shadow-lg overflow-hidden transform transition-all duration-500 hover:shadow-2xl"
|
||||||
|
style={{
|
||||||
|
animation: `fadeInUp 0.6s ease-out ${index * 0.1}s both`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpenSection(openSection === section.id ? null : section.id)}
|
||||||
|
className="w-full px-6 sm:px-8 py-6 flex items-center justify-between text-left bg-gradient-to-r from-white to-gray-50 hover:from-gray-50 hover:to-gray-100 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4 flex-1">
|
||||||
|
<div className="transform transition-transform duration-300 hover:scale-110" dangerouslySetInnerHTML={{ __html: section.icon }} />
|
||||||
|
<h2 className="text-xl sm:text-2xl font-bold text-gray-800">
|
||||||
|
{section.title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
className={`w-6 h-6 transition-transform duration-300 ${
|
||||||
|
openSection === section.id ? 'rotate-180' : ''
|
||||||
|
}`}
|
||||||
|
style={{ color: '#492E61' }}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`overflow-hidden transition-all duration-500 ${
|
||||||
|
openSection === section.id ? 'max-h-[1000px] opacity-100' : 'max-h-0 opacity-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="px-6 sm:px-8 py-6 bg-gradient-to-br from-gray-50 to-white border-t border-gray-100">
|
||||||
|
<div className="text-gray-700 leading-relaxed">
|
||||||
|
{section.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 text-center p-6 bg-white rounded-2xl shadow-lg">
|
||||||
|
<p className="text-gray-600">
|
||||||
|
<span className="font-semibold" style={{ color: '#492E61' }}>Última atualização:</span> Janeiro de 2025
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeInUp 0.8s ease-out;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
277
frontend/pages/Register.tsx
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Button } from '../components/Button';
|
||||||
|
import { Input } from '../components/Input';
|
||||||
|
import { InstitutionForm } from '../components/InstitutionForm';
|
||||||
|
import { useData } from '../contexts/DataContext';
|
||||||
|
|
||||||
|
interface RegisterProps {
|
||||||
|
onNavigate: (page: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Register: React.FC<RegisterProps> = ({ onNavigate }) => {
|
||||||
|
const { addInstitution } = useData();
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
wantsToAddInstitution: false
|
||||||
|
});
|
||||||
|
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [showInstitutionForm, setShowInstitutionForm] = useState(false);
|
||||||
|
const [tempUserId] = useState(`user-${Date.now()}`);
|
||||||
|
|
||||||
|
const handleChange = (field: string, value: string | boolean) => {
|
||||||
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
// Validação do checkbox de termos
|
||||||
|
if (!agreedToTerms) {
|
||||||
|
setError('Você precisa concordar com os termos de uso para continuar');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validações
|
||||||
|
if (formData.password !== formData.confirmPassword) {
|
||||||
|
setError('As senhas não coincidem');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.password.length < 6) {
|
||||||
|
setError('A senha deve ter no mínimo 6 caracteres');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user wants to add institution, show form
|
||||||
|
if (formData.wantsToAddInstitution) {
|
||||||
|
setIsLoading(false);
|
||||||
|
setShowInstitutionForm(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simular registro (conta será criada como Cliente/EVENT_OWNER automaticamente)
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
setSuccess(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
onNavigate('login');
|
||||||
|
}, 2000);
|
||||||
|
}, 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInstitutionSubmit = (institutionData: any) => {
|
||||||
|
const newInstitution = {
|
||||||
|
...institutionData,
|
||||||
|
id: `inst-${Date.now()}`,
|
||||||
|
ownerId: tempUserId
|
||||||
|
};
|
||||||
|
addInstitution(newInstitution);
|
||||||
|
setShowInstitutionForm(false);
|
||||||
|
|
||||||
|
// Complete registration
|
||||||
|
setIsLoading(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
setSuccess(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
onNavigate('login');
|
||||||
|
}, 2000);
|
||||||
|
}, 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show institution form modal
|
||||||
|
if (showInstitutionForm) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<InstitutionForm
|
||||||
|
onCancel={() => {
|
||||||
|
// Apenas fecha o modal e volta para o formulário
|
||||||
|
setShowInstitutionForm(false);
|
||||||
|
// Desmarca a opção de cadastrar universidade
|
||||||
|
setFormData(prev => ({ ...prev, wantsToAddInstitution: false }));
|
||||||
|
}}
|
||||||
|
onSubmit={handleInstitutionSubmit}
|
||||||
|
userId={tempUserId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-white">
|
||||||
|
<div className="text-center fade-in">
|
||||||
|
<div className="w-16 h-16 bg-green-500 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">Cadastro realizado com sucesso!</h2>
|
||||||
|
<p className="text-gray-600">Redirecionando para o login...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col lg:flex-row bg-white">
|
||||||
|
{/* Left Side - Image */}
|
||||||
|
<div className="hidden lg:flex lg:w-1/2 relative overflow-hidden">
|
||||||
|
<img
|
||||||
|
src="https://images.unsplash.com/photo-1541339907198-e08756dedf3f?fm=jpg&q=60&w=3000&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
|
||||||
|
alt="Photum Cadastro"
|
||||||
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-[#492E61]/90 to-[#492E61]/70 flex items-center justify-center">
|
||||||
|
<div className="text-center text-white p-12">
|
||||||
|
<h1 className="text-5xl font-serif font-bold mb-4">Faça parte da Photum</h1>
|
||||||
|
<p className="text-xl font-light tracking-wide max-w-md mx-auto">
|
||||||
|
Eternize seus momentos especiais com a melhor plataforma de gestão de eventos fotográficos.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Side - Form */}
|
||||||
|
<div className="w-full lg:w-1/2 flex items-center justify-center p-4 sm:p-6 md:p-8 lg:p-16">
|
||||||
|
<div className="max-w-md w-full space-y-6 sm:space-y-8 fade-in">
|
||||||
|
<div className="text-center lg:text-left">
|
||||||
|
<span className="font-bold tracking-widest uppercase text-xs sm:text-sm" style={{color: '#B9CF33'}}>Comece agora</span>
|
||||||
|
<h2 className="mt-2 text-2xl sm:text-3xl font-serif font-bold text-gray-900">Crie sua conta</h2>
|
||||||
|
<p className="mt-2 text-xs sm:text-sm text-gray-600">
|
||||||
|
Já tem uma conta?{' '}
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate('login')}
|
||||||
|
className="font-medium hover:opacity-80 transition-opacity"
|
||||||
|
style={{color: '#B9CF33'}}
|
||||||
|
>
|
||||||
|
Faça login
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="mt-6 sm:mt-8 space-y-4 sm:space-y-5" onSubmit={handleSubmit}>
|
||||||
|
<div className="space-y-3 sm:space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Nome Completo"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="João Silva"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => handleChange('name', e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="E-mail"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
placeholder="nome@exemplo.com"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => handleChange('email', e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Telefone"
|
||||||
|
type="tel"
|
||||||
|
required
|
||||||
|
placeholder="(00) 00000-0000"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={(e) => handleChange('phone', e.target.value)}
|
||||||
|
mask="phone"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Senha"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => handleChange('password', e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Confirmar Senha"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={formData.confirmPassword}
|
||||||
|
onChange={(e) => handleChange('confirmPassword', e.target.value)}
|
||||||
|
error={error && (error.includes('senha') || error.includes('coincidem')) ? error : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-start">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={agreedToTerms}
|
||||||
|
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
||||||
|
className="mt-0.5 sm:mt-1 h-4 w-4 flex-shrink-0 border-gray-300 rounded focus:ring-2"
|
||||||
|
style={{accentColor: '#B9CF33'}}
|
||||||
|
/>
|
||||||
|
<label className="ml-2 text-xs sm:text-sm text-gray-600">
|
||||||
|
Concordo com os{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onNavigate('terms')}
|
||||||
|
className="hover:opacity-80 transition-opacity underline"
|
||||||
|
style={{color: '#B9CF33'}}
|
||||||
|
>
|
||||||
|
termos de uso
|
||||||
|
</button>{' '}
|
||||||
|
e{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onNavigate('privacy')}
|
||||||
|
className="hover:opacity-80 transition-opacity underline"
|
||||||
|
style={{color: '#B9CF33'}}
|
||||||
|
>
|
||||||
|
política de privacidade
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{error && error.includes('termos') && (
|
||||||
|
<span className="text-xs text-red-500 mt-1 block ml-6">{error}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start bg-gray-50 border border-gray-200 rounded-lg p-3 sm:p-4">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.wantsToAddInstitution}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, wantsToAddInstitution: e.target.checked }))}
|
||||||
|
className="mt-0.5 sm:mt-1 h-4 w-4 flex-shrink-0 border-gray-300 rounded focus:ring-2"
|
||||||
|
style={{accentColor: '#B9CF33'}}
|
||||||
|
/>
|
||||||
|
<label className="ml-2 text-xs sm:text-sm text-gray-700">
|
||||||
|
<span className="font-medium text-xs sm:text-sm">Cadastrar universidade agora (Opcional)</span>
|
||||||
|
<p className="text-[10px] sm:text-xs text-gray-500 mt-1">
|
||||||
|
Você pode cadastrar sua universidade durante o cadastro ou posteriormente no sistema.
|
||||||
|
Trabalhamos exclusivamente com eventos fotográficos em universidades.
|
||||||
|
</p>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" size="lg" isLoading={isLoading}>
|
||||||
|
{formData.wantsToAddInstitution ? 'Continuar para Universidade' : 'Criar Conta'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
482
frontend/pages/Settings.tsx
Normal file
|
|
@ -0,0 +1,482 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { User, Mail, Phone, MapPin, Lock, Bell, Palette, Globe, Save, Camera } from 'lucide-react';
|
||||||
|
import { Button } from '../components/Button';
|
||||||
|
|
||||||
|
export const SettingsPage: React.FC = () => {
|
||||||
|
const [activeTab, setActiveTab] = useState<'profile' | 'account' | 'notifications' | 'appearance'>('profile');
|
||||||
|
const [profileData, setProfileData] = useState({
|
||||||
|
name: 'João Silva',
|
||||||
|
email: 'joao.silva@photum.com',
|
||||||
|
phone: '(41) 99999-0000',
|
||||||
|
location: 'Curitiba, PR',
|
||||||
|
bio: 'Fotógrafo profissional especializado em eventos e formaturas há mais de 10 anos.',
|
||||||
|
avatar: 'https://i.pravatar.cc/150?img=68'
|
||||||
|
});
|
||||||
|
|
||||||
|
const [notificationSettings, setNotificationSettings] = useState({
|
||||||
|
emailNotifications: true,
|
||||||
|
pushNotifications: true,
|
||||||
|
smsNotifications: false,
|
||||||
|
eventReminders: true,
|
||||||
|
paymentAlerts: true,
|
||||||
|
teamUpdates: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const [appearanceSettings, setAppearanceSettings] = useState({
|
||||||
|
theme: 'light',
|
||||||
|
language: 'pt-BR',
|
||||||
|
dateFormat: 'DD/MM/YYYY',
|
||||||
|
currency: 'BRL'
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSaveProfile = () => {
|
||||||
|
alert('Perfil atualizado com sucesso!');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveNotifications = () => {
|
||||||
|
alert('Configurações de notificações salvas!');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveAppearance = () => {
|
||||||
|
alert('Configurações de aparência salvas!');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 pt-32 pb-12">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-serif font-bold text-brand-black mb-2">
|
||||||
|
Configurações
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Gerencie suas preferências e informações da conta
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
|
<nav className="space-y-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('profile')}
|
||||||
|
className={`w-full flex items-center gap-3 px-4 py-3 rounded-md transition-colors ${activeTab === 'profile'
|
||||||
|
? 'bg-brand-gold text-white'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<User size={20} />
|
||||||
|
<span className="font-medium">Perfil</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('account')}
|
||||||
|
className={`w-full flex items-center gap-3 px-4 py-3 rounded-md transition-colors ${activeTab === 'account'
|
||||||
|
? 'bg-brand-gold text-white'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Lock size={20} />
|
||||||
|
<span className="font-medium">Conta</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('notifications')}
|
||||||
|
className={`w-full flex items-center gap-3 px-4 py-3 rounded-md transition-colors ${activeTab === 'notifications'
|
||||||
|
? 'bg-brand-gold text-white'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Bell size={20} />
|
||||||
|
<span className="font-medium">Notificações</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('appearance')}
|
||||||
|
className={`w-full flex items-center gap-3 px-4 py-3 rounded-md transition-colors ${activeTab === 'appearance'
|
||||||
|
? 'bg-brand-gold text-white'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Palette size={20} />
|
||||||
|
<span className="font-medium">Aparência</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-8">
|
||||||
|
{/* Profile Tab */}
|
||||||
|
{activeTab === 'profile' && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold mb-6">Informações do Perfil</h2>
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="relative">
|
||||||
|
<img
|
||||||
|
src={profileData.avatar}
|
||||||
|
alt="Avatar"
|
||||||
|
className="w-24 h-24 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
<button className="absolute bottom-0 right-0 w-8 h-8 bg-brand-gold text-white rounded-full flex items-center justify-center hover:bg-[#a5bd2e] transition-colors">
|
||||||
|
<Camera size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg">{profileData.name}</h3>
|
||||||
|
<p className="text-sm text-gray-600">{profileData.email}</p>
|
||||||
|
<button className="text-sm text-brand-gold hover:underline mt-1">
|
||||||
|
Alterar foto
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Nome Completo
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={profileData.name}
|
||||||
|
onChange={(e) => setProfileData({ ...profileData, name: e.target.value })}
|
||||||
|
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={profileData.email}
|
||||||
|
onChange={(e) => setProfileData({ ...profileData, email: e.target.value })}
|
||||||
|
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Telefone
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Phone className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={profileData.phone}
|
||||||
|
onChange={(e) => setProfileData({ ...profileData, phone: e.target.value })}
|
||||||
|
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Localização
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<MapPin className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={profileData.location}
|
||||||
|
onChange={(e) => setProfileData({ ...profileData, location: e.target.value })}
|
||||||
|
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Biografia
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={profileData.bio}
|
||||||
|
onChange={(e) => setProfileData({ ...profileData, bio: e.target.value })}
|
||||||
|
rows={4}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<Button size="lg" variant="secondary" onClick={handleSaveProfile}>
|
||||||
|
<Save size={20} className="mr-2" />
|
||||||
|
Salvar Alterações
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Account Tab */}
|
||||||
|
{activeTab === 'account' && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold mb-6">Segurança da Conta</h2>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Senha Atual
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Digite sua senha atual"
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Nova Senha
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Digite sua nova senha"
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Confirmar Nova Senha
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Confirme sua nova senha"
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<Button size="lg" variant="secondary">
|
||||||
|
<Lock size={20} className="mr-2" />
|
||||||
|
Atualizar Senha
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-8 border-t border-gray-200">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Autenticação em Dois Fatores</h3>
|
||||||
|
<p className="text-gray-600 mb-4">
|
||||||
|
Adicione uma camada extra de segurança à sua conta
|
||||||
|
</p>
|
||||||
|
<Button size="md" variant="outline">
|
||||||
|
Ativar 2FA
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notifications Tab */}
|
||||||
|
{activeTab === 'notifications' && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold mb-6">Preferências de Notificações</h2>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between py-4 border-b border-gray-200">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium">Notificações por Email</h3>
|
||||||
|
<p className="text-sm text-gray-600">Receba atualizações por email</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={notificationSettings.emailNotifications}
|
||||||
|
onChange={(e) => setNotificationSettings({
|
||||||
|
...notificationSettings,
|
||||||
|
emailNotifications: e.target.checked
|
||||||
|
})}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-gold/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-brand-gold"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between py-4 border-b border-gray-200">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium">Notificações Push</h3>
|
||||||
|
<p className="text-sm text-gray-600">Receba notificações no navegador</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={notificationSettings.pushNotifications}
|
||||||
|
onChange={(e) => setNotificationSettings({
|
||||||
|
...notificationSettings,
|
||||||
|
pushNotifications: e.target.checked
|
||||||
|
})}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-gold/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-brand-gold"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between py-4 border-b border-gray-200">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium">SMS</h3>
|
||||||
|
<p className="text-sm text-gray-600">Receba mensagens de texto</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={notificationSettings.smsNotifications}
|
||||||
|
onChange={(e) => setNotificationSettings({
|
||||||
|
...notificationSettings,
|
||||||
|
smsNotifications: e.target.checked
|
||||||
|
})}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-gold/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-brand-gold"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between py-4 border-b border-gray-200">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium">Lembretes de Eventos</h3>
|
||||||
|
<p className="text-sm text-gray-600">Receba lembretes antes dos eventos</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={notificationSettings.eventReminders}
|
||||||
|
onChange={(e) => setNotificationSettings({
|
||||||
|
...notificationSettings,
|
||||||
|
eventReminders: e.target.checked
|
||||||
|
})}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-gold/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-brand-gold"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between py-4 border-b border-gray-200">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium">Alertas de Pagamento</h3>
|
||||||
|
<p className="text-sm text-gray-600">Notificações sobre pagamentos</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={notificationSettings.paymentAlerts}
|
||||||
|
onChange={(e) => setNotificationSettings({
|
||||||
|
...notificationSettings,
|
||||||
|
paymentAlerts: e.target.checked
|
||||||
|
})}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-gold/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-brand-gold"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<Button size="lg" variant="secondary" onClick={handleSaveNotifications}>
|
||||||
|
<Save size={20} className="mr-2" />
|
||||||
|
Salvar Preferências
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Appearance Tab */}
|
||||||
|
{activeTab === 'appearance' && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold mb-6">Aparência e Idioma</h2>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Tema
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={appearanceSettings.theme}
|
||||||
|
onChange={(e) => setAppearanceSettings({
|
||||||
|
...appearanceSettings,
|
||||||
|
theme: e.target.value
|
||||||
|
})}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||||
|
>
|
||||||
|
<option value="light">Claro</option>
|
||||||
|
<option value="dark">Escuro</option>
|
||||||
|
<option value="auto">Automático</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Idioma
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={appearanceSettings.language}
|
||||||
|
onChange={(e) => setAppearanceSettings({
|
||||||
|
...appearanceSettings,
|
||||||
|
language: e.target.value
|
||||||
|
})}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||||
|
>
|
||||||
|
<option value="pt-BR">Português (Brasil)</option>
|
||||||
|
<option value="en-US">English (US)</option>
|
||||||
|
<option value="es-ES">Español</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Formato de Data
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={appearanceSettings.dateFormat}
|
||||||
|
onChange={(e) => setAppearanceSettings({
|
||||||
|
...appearanceSettings,
|
||||||
|
dateFormat: e.target.value
|
||||||
|
})}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||||
|
>
|
||||||
|
<option value="DD/MM/YYYY">DD/MM/YYYY</option>
|
||||||
|
<option value="MM/DD/YYYY">MM/DD/YYYY</option>
|
||||||
|
<option value="YYYY-MM-DD">YYYY-MM-DD</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Moeda
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={appearanceSettings.currency}
|
||||||
|
onChange={(e) => setAppearanceSettings({
|
||||||
|
...appearanceSettings,
|
||||||
|
currency: e.target.value
|
||||||
|
})}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||||
|
>
|
||||||
|
<option value="BRL">Real (R$)</option>
|
||||||
|
<option value="USD">Dólar ($)</option>
|
||||||
|
<option value="EUR">Euro (€)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<Button size="lg" variant="secondary" onClick={handleSaveAppearance}>
|
||||||
|
<Save size={20} className="mr-2" />
|
||||||
|
Salvar Configurações
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
759
frontend/pages/Team.tsx
Normal file
|
|
@ -0,0 +1,759 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Camera,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
MapPin,
|
||||||
|
Star,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
User,
|
||||||
|
Upload,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "../components/Button";
|
||||||
|
|
||||||
|
interface Photographer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
location: string;
|
||||||
|
specialties: string[];
|
||||||
|
rating: number;
|
||||||
|
eventsCompleted: number;
|
||||||
|
status: "active" | "inactive" | "busy";
|
||||||
|
avatar: string;
|
||||||
|
joinDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_PHOTOGRAPHERS: Photographer[] = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
name: "Carlos Silva",
|
||||||
|
email: "carlos.silva@photum.com",
|
||||||
|
phone: "(41) 99999-1111",
|
||||||
|
location: "Curitiba, PR",
|
||||||
|
specialties: ["Formaturas", "Eventos Corporativos"],
|
||||||
|
rating: 4.8,
|
||||||
|
eventsCompleted: 45,
|
||||||
|
status: "active",
|
||||||
|
avatar: "https://i.pravatar.cc/150?img=12",
|
||||||
|
joinDate: "2023-01-15",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
name: "Ana Paula Mendes",
|
||||||
|
email: "ana.mendes@photum.com",
|
||||||
|
phone: "(41) 99999-2222",
|
||||||
|
location: "Curitiba, PR",
|
||||||
|
specialties: ["Casamentos", "Formaturas"],
|
||||||
|
rating: 4.9,
|
||||||
|
eventsCompleted: 62,
|
||||||
|
status: "busy",
|
||||||
|
avatar: "https://i.pravatar.cc/150?img=5",
|
||||||
|
joinDate: "2022-08-20",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
name: "Roberto Costa",
|
||||||
|
email: "roberto.costa@photum.com",
|
||||||
|
phone: "(41) 99999-3333",
|
||||||
|
location: "São José dos Pinhais, PR",
|
||||||
|
specialties: ["Formaturas", "Eventos Sociais"],
|
||||||
|
rating: 4.7,
|
||||||
|
eventsCompleted: 38,
|
||||||
|
status: "active",
|
||||||
|
avatar: "https://i.pravatar.cc/150?img=33",
|
||||||
|
joinDate: "2023-03-10",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
name: "Juliana Santos",
|
||||||
|
email: "juliana.santos@photum.com",
|
||||||
|
phone: "(41) 99999-4444",
|
||||||
|
location: "Curitiba, PR",
|
||||||
|
specialties: ["Casamentos", "Ensaios"],
|
||||||
|
rating: 5.0,
|
||||||
|
eventsCompleted: 71,
|
||||||
|
status: "active",
|
||||||
|
avatar: "https://i.pravatar.cc/150?img=9",
|
||||||
|
joinDate: "2022-05-12",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "5",
|
||||||
|
name: "Fernando Oliveira",
|
||||||
|
email: "fernando.oliveira@photum.com",
|
||||||
|
phone: "(41) 99999-5555",
|
||||||
|
location: "Pinhais, PR",
|
||||||
|
specialties: ["Eventos Corporativos", "Formaturas"],
|
||||||
|
rating: 4.6,
|
||||||
|
eventsCompleted: 29,
|
||||||
|
status: "inactive",
|
||||||
|
avatar: "https://i.pravatar.cc/150?img=15",
|
||||||
|
joinDate: "2023-07-01",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "6",
|
||||||
|
name: "Mariana Rodrigues",
|
||||||
|
email: "mariana.rodrigues@photum.com",
|
||||||
|
phone: "(41) 99999-6666",
|
||||||
|
location: "Curitiba, PR",
|
||||||
|
specialties: ["Formaturas", "Eventos Sociais", "Casamentos"],
|
||||||
|
rating: 4.9,
|
||||||
|
eventsCompleted: 54,
|
||||||
|
status: "busy",
|
||||||
|
avatar: "https://i.pravatar.cc/150?img=10",
|
||||||
|
joinDate: "2022-11-05",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TeamPage: React.FC = () => {
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<
|
||||||
|
"all" | "active" | "busy" | "inactive"
|
||||||
|
>("all");
|
||||||
|
const [selectedPhotographer, setSelectedPhotographer] =
|
||||||
|
useState<Photographer | null>(null);
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
|
const [newPhotographer, setNewPhotographer] = useState({
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
location: "",
|
||||||
|
specialties: [] as string[],
|
||||||
|
avatar: "",
|
||||||
|
});
|
||||||
|
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||||
|
const [avatarPreview, setAvatarPreview] = useState<string>("");
|
||||||
|
|
||||||
|
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
setAvatarFile(file);
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
setAvatarPreview(reader.result as string);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAvatar = () => {
|
||||||
|
setAvatarFile(null);
|
||||||
|
setAvatarPreview("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: Photographer["status"]) => {
|
||||||
|
switch (status) {
|
||||||
|
case "active":
|
||||||
|
return "bg-green-100 text-green-800";
|
||||||
|
case "busy":
|
||||||
|
return "bg-yellow-100 text-yellow-800";
|
||||||
|
case "inactive":
|
||||||
|
return "bg-gray-100 text-gray-800";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status: Photographer["status"]) => {
|
||||||
|
switch (status) {
|
||||||
|
case "active":
|
||||||
|
return "Disponível";
|
||||||
|
case "busy":
|
||||||
|
return "Em Evento";
|
||||||
|
case "inactive":
|
||||||
|
return "Inativo";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredPhotographers = MOCK_PHOTOGRAPHERS.filter((photographer) => {
|
||||||
|
const matchesSearch =
|
||||||
|
photographer.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
photographer.email.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
const matchesStatus =
|
||||||
|
statusFilter === "all" || photographer.status === statusFilter;
|
||||||
|
return matchesSearch && matchesStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: MOCK_PHOTOGRAPHERS.length,
|
||||||
|
active: MOCK_PHOTOGRAPHERS.filter((p) => p.status === "active").length,
|
||||||
|
busy: MOCK_PHOTOGRAPHERS.filter((p) => p.status === "busy").length,
|
||||||
|
avgRating: (
|
||||||
|
MOCK_PHOTOGRAPHERS.reduce((acc, p) => acc + p.rating, 0) /
|
||||||
|
MOCK_PHOTOGRAPHERS.length
|
||||||
|
).toFixed(1),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 pt-32 pb-12">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-serif font-bold text-brand-black mb-2">
|
||||||
|
Equipe & Fotógrafos
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Gerencie sua equipe de fotógrafos profissionais
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 mb-1">
|
||||||
|
Total de Fotógrafos
|
||||||
|
</p>
|
||||||
|
<p className="text-3xl font-bold text-brand-black">
|
||||||
|
{stats.total}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Users className="text-brand-gold" size={32} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 mb-1">Disponíveis</p>
|
||||||
|
<p className="text-3xl font-bold text-green-600">
|
||||||
|
{stats.active}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Camera className="text-green-600" size={32} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 mb-1">Em Evento</p>
|
||||||
|
<p className="text-3xl font-bold text-yellow-600">
|
||||||
|
{stats.busy}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Camera className="text-yellow-600" size={32} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 mb-1">Avaliação Média</p>
|
||||||
|
<p className="text-3xl font-bold text-brand-gold">
|
||||||
|
{stats.avgRating}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Star className="text-brand-gold" size={32} fill="#B9CF33" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters and Search */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
|
||||||
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search
|
||||||
|
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por nome ou email..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter("all")}
|
||||||
|
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
||||||
|
statusFilter === "all"
|
||||||
|
? "bg-brand-gold text-white"
|
||||||
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Todos
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter("active")}
|
||||||
|
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
||||||
|
statusFilter === "active"
|
||||||
|
? "bg-green-600 text-white"
|
||||||
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Disponíveis
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter("busy")}
|
||||||
|
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
||||||
|
statusFilter === "busy"
|
||||||
|
? "bg-yellow-600 text-white"
|
||||||
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Em Evento
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setShowAddModal(true)}
|
||||||
|
>
|
||||||
|
<Plus size={20} className="mr-2" />
|
||||||
|
Adicionar Fotógrafo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Photographers Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{filteredPhotographers.map((photographer) => (
|
||||||
|
<div
|
||||||
|
key={photographer.id}
|
||||||
|
className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow cursor-pointer"
|
||||||
|
onClick={() => setSelectedPhotographer(photographer)}
|
||||||
|
>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<img
|
||||||
|
src={photographer.avatar}
|
||||||
|
alt={photographer.name}
|
||||||
|
className="w-16 h-16 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg text-brand-black">
|
||||||
|
{photographer.name}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-1 mt-1">
|
||||||
|
<Star
|
||||||
|
size={14}
|
||||||
|
fill="#B9CF33"
|
||||||
|
className="text-brand-gold"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{photographer.rating}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
({photographer.eventsCompleted} eventos)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(
|
||||||
|
photographer.status
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{getStatusLabel(photographer.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
<div className="flex items-center text-sm text-gray-600">
|
||||||
|
<Mail size={16} className="mr-2 text-brand-gold" />
|
||||||
|
{photographer.email}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center text-sm text-gray-600">
|
||||||
|
<Phone size={16} className="mr-2 text-brand-gold" />
|
||||||
|
{photographer.phone}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center text-sm text-gray-600">
|
||||||
|
<MapPin size={16} className="mr-2 text-brand-gold" />
|
||||||
|
{photographer.location}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{photographer.specialties.map((specialty, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="px-2 py-1 bg-gray-100 text-gray-700 rounded-full text-xs font-medium"
|
||||||
|
>
|
||||||
|
{specialty}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredPhotographers.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Users size={48} className="mx-auto text-gray-300 mb-4" />
|
||||||
|
<p className="text-gray-500">Nenhum fotógrafo encontrado</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Photographer Modal */}
|
||||||
|
{showAddModal && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||||
|
onClick={() => setShowAddModal(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-white rounded-lg max-w-2xl w-full p-8 max-h-[90vh] overflow-y-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-2xl font-serif font-bold text-brand-black">
|
||||||
|
Adicionar Novo Fotógrafo
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddModal(false)}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="space-y-6"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
alert(
|
||||||
|
"Fotógrafo adicionado com sucesso!\n\n" +
|
||||||
|
JSON.stringify(
|
||||||
|
{ ...newPhotographer, avatarFile: avatarFile?.name },
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setShowAddModal(false);
|
||||||
|
setNewPhotographer({
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
location: "",
|
||||||
|
specialties: [],
|
||||||
|
avatar: "",
|
||||||
|
});
|
||||||
|
setAvatarFile(null);
|
||||||
|
setAvatarPreview("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Foto de Perfil
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{avatarPreview ? (
|
||||||
|
<div className="relative">
|
||||||
|
<img
|
||||||
|
src={avatarPreview}
|
||||||
|
alt="Preview"
|
||||||
|
className="w-24 h-24 rounded-full object-cover border-2 border-gray-200"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={removeAvatar}
|
||||||
|
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 hover:bg-red-600 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-24 h-24 rounded-full bg-gray-100 border-2 border-dashed border-gray-300 flex items-center justify-center">
|
||||||
|
<Camera size={32} className="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="cursor-pointer">
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 bg-gray-50 border border-gray-300 rounded-md hover:bg-gray-100 transition-colors w-fit">
|
||||||
|
<Upload size={18} className="text-gray-600" />
|
||||||
|
<span className="text-sm font-medium text-gray-700">
|
||||||
|
{avatarFile ? "Trocar foto" : "Selecionar foto"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleAvatarChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
JPG, PNG ou GIF (máx. 5MB)
|
||||||
|
</p>
|
||||||
|
{avatarFile && (
|
||||||
|
<p className="text-xs text-brand-gold mt-1 font-medium">
|
||||||
|
{avatarFile.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Nome Completo *
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<User
|
||||||
|
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={newPhotographer.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewPhotographer({
|
||||||
|
...newPhotographer,
|
||||||
|
name: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="Ex: João Silva"
|
||||||
|
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Email *
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail
|
||||||
|
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={newPhotographer.email}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewPhotographer({
|
||||||
|
...newPhotographer,
|
||||||
|
email: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="joao.silva@photum.com"
|
||||||
|
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Telefone *
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Phone
|
||||||
|
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
required
|
||||||
|
value={newPhotographer.phone}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewPhotographer({
|
||||||
|
...newPhotographer,
|
||||||
|
phone: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="(41) 99999-0000"
|
||||||
|
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Localização *
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<MapPin
|
||||||
|
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={newPhotographer.location}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewPhotographer({
|
||||||
|
...newPhotographer,
|
||||||
|
location: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="Curitiba, PR"
|
||||||
|
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Especialidades
|
||||||
|
</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[
|
||||||
|
"Formaturas",
|
||||||
|
"Casamentos",
|
||||||
|
"Eventos Corporativos",
|
||||||
|
"Eventos Sociais",
|
||||||
|
"Ensaios",
|
||||||
|
].map((specialty) => (
|
||||||
|
<label
|
||||||
|
key={specialty}
|
||||||
|
className="flex items-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={newPhotographer.specialties.includes(
|
||||||
|
specialty
|
||||||
|
)}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setNewPhotographer({
|
||||||
|
...newPhotographer,
|
||||||
|
specialties: [
|
||||||
|
...newPhotographer.specialties,
|
||||||
|
specialty,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setNewPhotographer({
|
||||||
|
...newPhotographer,
|
||||||
|
specialties: newPhotographer.specialties.filter(
|
||||||
|
(s) => s !== specialty
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-4 h-4 text-brand-gold focus:ring-brand-gold border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">{specialty}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-6 border-t border-gray-200 flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAddModal(false)}
|
||||||
|
className="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 px-6 py-3 bg-brand-gold text-white rounded-md hover:bg-[#a5bd2e] transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Adicionar Fotógrafo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Photographer Detail Modal */}
|
||||||
|
{selectedPhotographer && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||||
|
onClick={() => setSelectedPhotographer(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-white rounded-lg max-w-2xl w-full p-8"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4 mb-6">
|
||||||
|
<img
|
||||||
|
src={selectedPhotographer.avatar}
|
||||||
|
alt={selectedPhotographer.name}
|
||||||
|
className="w-24 h-24 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-serif font-bold text-brand-black mb-1">
|
||||||
|
{selectedPhotographer.name}
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Star
|
||||||
|
size={18}
|
||||||
|
fill="#B9CF33"
|
||||||
|
className="text-brand-gold"
|
||||||
|
/>
|
||||||
|
<span className="font-semibold">
|
||||||
|
{selectedPhotographer.rating}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
({selectedPhotographer.eventsCompleted} eventos
|
||||||
|
concluídos)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`inline-block px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(
|
||||||
|
selectedPhotographer.status
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{getStatusLabel(selectedPhotographer.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedPhotographer(null)}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 mb-6">
|
||||||
|
<div className="flex items-center text-gray-700">
|
||||||
|
<Mail size={20} className="mr-3 text-brand-gold" />
|
||||||
|
<span>{selectedPhotographer.email}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center text-gray-700">
|
||||||
|
<Phone size={20} className="mr-3 text-brand-gold" />
|
||||||
|
<span>{selectedPhotographer.phone}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center text-gray-700">
|
||||||
|
<MapPin size={20} className="mr-3 text-brand-gold" />
|
||||||
|
<span>{selectedPhotographer.location}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="font-semibold mb-2">Especialidades</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{selectedPhotographer.specialties.map((specialty, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="px-3 py-1 bg-brand-gold/10 text-brand-gold rounded-full text-sm font-medium"
|
||||||
|
>
|
||||||
|
{specialty}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-6 border-t border-gray-200 flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedPhotographer(null)}
|
||||||
|
className="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Fechar
|
||||||
|
</button>
|
||||||
|
<button className="flex-1 px-6 py-3 bg-brand-gold text-white rounded-md hover:bg-[#a5bd2e] transition-colors font-medium">
|
||||||
|
Ver Agenda
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
220
frontend/pages/TermsOfUse.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
interface TermsOfUseProps {
|
||||||
|
onNavigate: (page: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Section {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
content: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TermsOfUse: React.FC<TermsOfUseProps> = ({ onNavigate }) => {
|
||||||
|
const [openSection, setOpenSection] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const sections: Section[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Aceitação dos Termos',
|
||||||
|
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>',
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
Ao acessar e usar os serviços da Photum Formaturas, você concorda em cumprir e estar vinculado
|
||||||
|
a estes Termos de Uso. Se você não concordar com algum destes termos, não utilize nossos serviços.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'Descrição dos Serviços',
|
||||||
|
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" /></svg>',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<p className="mb-4">A Photum Formaturas oferece:</p>
|
||||||
|
<ul className="list-none space-y-2">
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Serviços de fotografia para formaturas</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Álbuns digitais e físicos</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Galeria online para visualização e download de fotos</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Serviços personalizados de acordo com as necessidades do cliente</li>
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: 'Conta de Usuário',
|
||||||
|
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<p className="mb-4">Para acessar alguns recursos, você precisará criar uma conta. Você concorda em:</p>
|
||||||
|
<ul className="list-none space-y-2">
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Fornecer informações precisas e atualizadas</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Manter a segurança de sua senha</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Notificar-nos sobre qualquer uso não autorizado de sua conta</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Ser responsável por todas as atividades em sua conta</li>
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: 'Direitos de Propriedade Intelectual',
|
||||||
|
icon: '<svg className="w-12 h-12" fill="none" stroke="#492E61" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" strokeWidth={2} /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 9a3 3 0 00-3-3 3 3 0 00-3 3m0 6a3 3 0 003 3 3 3 0 003-3" /></svg>',
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
Todas as fotografias e conteúdos produzidos pela Photum Formaturas são protegidos por direitos autorais.
|
||||||
|
Os clientes recebem uma licença para uso pessoal das fotos adquiridas, mas a reprodução comercial
|
||||||
|
requer autorização prévia.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
title: 'Pagamentos e Reembolsos',
|
||||||
|
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" /></svg>',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<p className="mb-4">Nossos termos de pagamento incluem:</p>
|
||||||
|
<ul className="list-none space-y-2">
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Pagamento parcial na reserva do serviço</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Saldo remanescente até a data do evento</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Reembolsos disponíveis conforme política específica</li>
|
||||||
|
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Cancelamentos devem ser notificados com antecedência mínima</li>
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
title: 'Responsabilidades e Limitações',
|
||||||
|
icon: '<svg className="w-12 h-12" fill="none" stroke="#FFA500" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>',
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
A Photum Formaturas se esforça para fornecer serviços de alta qualidade, mas não se responsabiliza
|
||||||
|
por circunstâncias além de nosso controle, como condições climáticas adversas ou problemas técnicos
|
||||||
|
no local do evento.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
title: 'Modificações nos Termos',
|
||||||
|
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>',
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
Reservamo-nos o direito de modificar estes Termos de Uso a qualquer momento. Alterações significativas
|
||||||
|
serão notificadas aos usuários. O uso continuado dos serviços após mudanças constitui aceitação dos
|
||||||
|
novos termos.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
title: 'Contato',
|
||||||
|
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" /></svg>',
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
Para dúvidas sobre estes Termos de Uso, entre em contato conosco em:{' '}
|
||||||
|
<a href="mailto:contato@photum.com.br" className="font-semibold hover:opacity-80 transition-opacity" style={{color: '#B9CF33'}}>
|
||||||
|
contato@photum.com.br
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-white py-12 sm:py-20 px-4 sm:px-8">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate('home')}
|
||||||
|
className="mb-8 flex items-center gap-2 text-gray-600 hover:text-[#492E61] transition-all duration-300 group"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 transform group-hover:-translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Voltar para Home
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="text-center mb-12 fade-in">
|
||||||
|
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold mb-4" style={{ color: '#B9CF33' }}>
|
||||||
|
Termos de Uso
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 text-lg">Condições para uso de nossos serviços</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:gap-6">
|
||||||
|
{sections.map((section, index) => (
|
||||||
|
<div
|
||||||
|
key={section.id}
|
||||||
|
className="bg-white rounded-2xl shadow-lg overflow-hidden transform transition-all duration-500 hover:shadow-2xl"
|
||||||
|
style={{
|
||||||
|
animation: `fadeInUp 0.6s ease-out ${index * 0.1}s both`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpenSection(openSection === section.id ? null : section.id)}
|
||||||
|
className="w-full px-6 sm:px-8 py-6 flex items-center justify-between text-left bg-gradient-to-r from-white to-gray-50 hover:from-gray-50 hover:to-gray-100 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4 flex-1">
|
||||||
|
<div className="transform transition-transform duration-300 hover:scale-110" dangerouslySetInnerHTML={{ __html: section.icon }} />
|
||||||
|
<h2 className="text-xl sm:text-2xl font-bold text-gray-800">
|
||||||
|
{section.title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
className={`w-6 h-6 transition-transform duration-300 ${
|
||||||
|
openSection === section.id ? 'rotate-180' : ''
|
||||||
|
}`}
|
||||||
|
style={{ color: '#492E61' }}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`overflow-hidden transition-all duration-500 ${
|
||||||
|
openSection === section.id ? 'max-h-[1000px] opacity-100' : 'max-h-0 opacity-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="px-6 sm:px-8 py-6 bg-gradient-to-br from-gray-50 to-white border-t border-gray-100">
|
||||||
|
<div className="text-gray-700 leading-relaxed">
|
||||||
|
{section.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 text-center p-6 bg-white rounded-2xl shadow-lg">
|
||||||
|
<p className="text-gray-600">
|
||||||
|
<span className="font-semibold" style={{ color: '#492E61' }}>Última atualização:</span> Janeiro de 2025
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeInUp 0.8s ease-out;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
BIN
frontend/public/HOME_01.jpg
Normal file
|
After Width: | Height: | Size: 711 KiB |
BIN
frontend/public/HOME_14.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
frontend/public/HOME_15.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
frontend/public/HOME_16.png
Normal file
|
After Width: | Height: | Size: 933 KiB |
BIN
frontend/public/HOME_17 (1).png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
frontend/public/HOME_17.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
frontend/public/banner2.jpg
Normal file
|
After Width: | Height: | Size: 510 KiB |
BIN
frontend/public/favicon.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
frontend/public/logo.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
frontend/public/logo23.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
frontend/public/logofav.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
23
frontend/services/genaiService.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
// Gemini API service temporarily disabled - requires API key configuration
|
||||||
|
// import { GoogleGenAI } from "@google/genai";
|
||||||
|
// const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
|
||||||
|
|
||||||
|
export interface GeoResult {
|
||||||
|
street: string;
|
||||||
|
number: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
zip: string;
|
||||||
|
description: string;
|
||||||
|
mapLink?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Temporarily disabled - requires API key setup
|
||||||
|
export const searchLocationWithGemini = async (
|
||||||
|
query: string
|
||||||
|
): Promise<GeoResult[]> => {
|
||||||
|
console.warn(
|
||||||
|
"Gemini location search is disabled. Please configure API key to enable."
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
};
|
||||||
156
frontend/services/mapboxService.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
// Mapbox Geocoding Service
|
||||||
|
// Docs: https://docs.mapbox.com/api/search/geocoding/
|
||||||
|
|
||||||
|
export interface MapboxFeature {
|
||||||
|
id: string;
|
||||||
|
place_name: string;
|
||||||
|
center: [number, number]; // [longitude, latitude]
|
||||||
|
geometry: {
|
||||||
|
coordinates: [number, number];
|
||||||
|
};
|
||||||
|
context?: Array<{
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
short_code?: string;
|
||||||
|
}>;
|
||||||
|
place_type: string[];
|
||||||
|
text: string;
|
||||||
|
address?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapboxResult {
|
||||||
|
description: string;
|
||||||
|
street: string;
|
||||||
|
number: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
zip: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
mapLink: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token do Mapbox configurado no arquivo .env.local
|
||||||
|
const MAPBOX_TOKEN = import.meta.env.VITE_MAPBOX_TOKEN || "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca endereços usando a API de Geocoding do Mapbox
|
||||||
|
* @param query - Texto de busca (endereço, local, etc)
|
||||||
|
* @param country - Código do país (ex: 'br' para Brasil)
|
||||||
|
*/
|
||||||
|
export async function searchMapboxLocation(
|
||||||
|
query: string,
|
||||||
|
country: string = "br"
|
||||||
|
): Promise<MapboxResult[]> {
|
||||||
|
if (!MAPBOX_TOKEN || MAPBOX_TOKEN.startsWith("YOUR_")) {
|
||||||
|
console.warn(
|
||||||
|
"⚠️ Mapbox Token não configurado. Configure em services/mapboxService.ts"
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const encodedQuery = encodeURIComponent(query);
|
||||||
|
const url =
|
||||||
|
`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodedQuery}.json?` +
|
||||||
|
`access_token=${MAPBOX_TOKEN}&` +
|
||||||
|
`country=${country}&` +
|
||||||
|
`language=pt&` +
|
||||||
|
`limit=5`;
|
||||||
|
|
||||||
|
console.log("🔍 Buscando endereço:", query);
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error("❌ Erro na API Mapbox:", response.statusText);
|
||||||
|
throw new Error(`Mapbox API error: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log("✅ Resultados encontrados:", data.features?.length || 0);
|
||||||
|
|
||||||
|
if (!data.features || data.features.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.features.map((feature: MapboxFeature) => {
|
||||||
|
// Extrair informações do contexto
|
||||||
|
const context = feature.context || [];
|
||||||
|
const place = context.find((c) => c.id.startsWith("place"));
|
||||||
|
const region = context.find((c) => c.id.startsWith("region"));
|
||||||
|
const postcode = context.find((c) => c.id.startsWith("postcode"));
|
||||||
|
|
||||||
|
// Extrair número do endereço
|
||||||
|
const addressMatch =
|
||||||
|
feature.address || feature.text.match(/\d+/)?.[0] || "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
description: feature.place_name,
|
||||||
|
street: feature.text,
|
||||||
|
number: addressMatch,
|
||||||
|
city: place?.text || "",
|
||||||
|
state: region?.short_code?.replace("BR-", "") || region?.text || "",
|
||||||
|
zip: postcode?.text || "",
|
||||||
|
lat: feature.center[1],
|
||||||
|
lng: feature.center[0],
|
||||||
|
mapLink: `https://www.google.com/maps/search/?api=1&query=${feature.center[1]},${feature.center[0]}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao buscar localização no Mapbox:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca reversa: converte coordenadas em endereço
|
||||||
|
* Retorna o endereço completo baseado nas coordenadas do pin
|
||||||
|
*/
|
||||||
|
export async function reverseGeocode(
|
||||||
|
lat: number,
|
||||||
|
lng: number
|
||||||
|
): Promise<MapboxResult | null> {
|
||||||
|
if (!MAPBOX_TOKEN || MAPBOX_TOKEN.startsWith("YOUR_")) {
|
||||||
|
console.warn("⚠️ Mapbox Token não configurado");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url =
|
||||||
|
`https://api.mapbox.com/geocoding/v5/mapbox.places/${lng},${lat}.json?` +
|
||||||
|
`access_token=${MAPBOX_TOKEN}&` +
|
||||||
|
`language=pt&` +
|
||||||
|
`types=address,place`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.features && data.features.length > 0) {
|
||||||
|
const feature = data.features[0];
|
||||||
|
const context = feature.context || [];
|
||||||
|
const place = context.find((c: any) => c.id.startsWith("place"));
|
||||||
|
const region = context.find((c: any) => c.id.startsWith("region"));
|
||||||
|
const postcode = context.find((c: any) => c.id.startsWith("postcode"));
|
||||||
|
|
||||||
|
// Extrair número se houver
|
||||||
|
const addressMatch = feature.address || "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
description: feature.place_name,
|
||||||
|
street: feature.text,
|
||||||
|
number: addressMatch.toString(),
|
||||||
|
city: place?.text || "",
|
||||||
|
state: region?.short_code?.replace("BR-", "") || region?.text || "",
|
||||||
|
zip: postcode?.text || "",
|
||||||
|
lat,
|
||||||
|
lng,
|
||||||
|
mapLink: `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro no reverse geocode:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,26 +1,25 @@
|
||||||
|
|
||||||
export enum UserRole {
|
export enum UserRole {
|
||||||
SUPERADMIN = 'SUPERADMIN',
|
SUPERADMIN = "SUPERADMIN",
|
||||||
BUSINESS_OWNER = 'BUSINESS_OWNER',
|
BUSINESS_OWNER = "BUSINESS_OWNER",
|
||||||
EVENT_OWNER = 'EVENT_OWNER',
|
EVENT_OWNER = "EVENT_OWNER",
|
||||||
PHOTOGRAPHER = 'PHOTOGRAPHER'
|
PHOTOGRAPHER = "PHOTOGRAPHER",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum EventStatus {
|
export enum EventStatus {
|
||||||
PENDING_APPROVAL = 'Aguardando Aprovação', // Novo status para clientes
|
PENDING_APPROVAL = "Aguardando Aprovação", // Novo status para clientes
|
||||||
PLANNING = 'Em Planejamento',
|
PLANNING = "Em Planejamento",
|
||||||
CONFIRMED = 'Confirmado',
|
CONFIRMED = "Confirmado",
|
||||||
IN_PROGRESS = 'Em Execução',
|
IN_PROGRESS = "Em Execução",
|
||||||
DELIVERED = 'Entregue',
|
DELIVERED = "Entregue",
|
||||||
ARCHIVED = 'Arquivado'
|
ARCHIVED = "Arquivado",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum EventType {
|
export enum EventType {
|
||||||
WEDDING = 'Casamento',
|
WEDDING = "Casamento",
|
||||||
CORPORATE = 'Corporativo',
|
CORPORATE = "Corporativo",
|
||||||
BIRTHDAY = 'Aniversário',
|
BIRTHDAY = "Aniversário",
|
||||||
DEBUTANTE = 'Debutante',
|
DEBUTANTE = "Debutante",
|
||||||
OTHER = 'Outro'
|
OTHER = "Outro",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
|
|
@ -29,6 +28,19 @@ export interface User {
|
||||||
email: string;
|
email: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
|
institutionId?: string; // Instituição vinculada ao usuário
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Institution {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string; // Ex: Universidade Pública, Universidade Privada, Faculdade, etc.
|
||||||
|
cnpj?: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
address?: Address;
|
||||||
|
description?: string;
|
||||||
|
ownerId: string; // ID do usuário que criou a instituição
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Address {
|
export interface Address {
|
||||||
|
|
@ -57,13 +69,6 @@ export interface ChecklistItem {
|
||||||
required: boolean;
|
required: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Attachment {
|
|
||||||
name: string;
|
|
||||||
size: string;
|
|
||||||
type: string;
|
|
||||||
url?: string; // Added URL for gallery display
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EventData {
|
export interface EventData {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -76,7 +81,7 @@ export interface EventData {
|
||||||
checklist: ChecklistItem[];
|
checklist: ChecklistItem[];
|
||||||
briefing: string;
|
briefing: string;
|
||||||
coverImage: string;
|
coverImage: string;
|
||||||
attachments: Attachment[];
|
|
||||||
ownerId: string; // ID do cliente dono do evento
|
ownerId: string; // ID do cliente dono do evento
|
||||||
photographerIds: string[]; // IDs dos fotógrafos designados
|
photographerIds: string[]; // IDs dos fotógrafos designados
|
||||||
}
|
institutionId?: string; // ID da instituição vinculada (obrigatório)
|
||||||
|
}
|
||||||
9
frontend/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_MAPBOX_TOKEN: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
59
index.html
|
|
@ -1,59 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="pt-BR">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>PhotumManager - Gestão de Eventos</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Playfair+Display:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet">
|
|
||||||
<script>
|
|
||||||
tailwind.config = {
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
fontFamily: {
|
|
||||||
sans: ['Inter', 'sans-serif'],
|
|
||||||
serif: ['Playfair Display', 'serif'],
|
|
||||||
},
|
|
||||||
colors: {
|
|
||||||
brand: {
|
|
||||||
black: '#1a1a1a',
|
|
||||||
gold: '#B9CF33', // Updated from #c5a059 to #B9CF33
|
|
||||||
gray: '#f4f4f4',
|
|
||||||
darkgray: '#333333'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<style>
|
|
||||||
/* Smooth scrolling */
|
|
||||||
html { scroll-behavior: smooth; }
|
|
||||||
/* Custom scrollbar */
|
|
||||||
::-webkit-scrollbar { width: 8px; }
|
|
||||||
::-webkit-scrollbar-track { background: #f1f1f1; }
|
|
||||||
::-webkit-scrollbar-thumb { background: #B9CF33; border-radius: 4px; }
|
|
||||||
::-webkit-scrollbar-thumb:hover { background: #a5bd2e; }
|
|
||||||
|
|
||||||
.fade-in { animation: fadeIn 0.5s ease-out forwards; }
|
|
||||||
.slide-up { animation: slideUp 0.6s ease-out forwards; }
|
|
||||||
|
|
||||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
|
||||||
@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
|
|
||||||
</style>
|
|
||||||
<script type="importmap">
|
|
||||||
{
|
|
||||||
"imports": {
|
|
||||||
"react/": "https://aistudiocdn.com/react@^19.2.0/",
|
|
||||||
"react": "https://aistudiocdn.com/react@^19.2.0",
|
|
||||||
"lucide-react": "https://aistudiocdn.com/lucide-react@^0.554.0",
|
|
||||||
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/",
|
|
||||||
"@google/genai": "https://aistudiocdn.com/@google/genai@^1.30.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body class="bg-white text-brand-black antialiased selection:bg-brand-gold selection:text-white">
|
|
||||||
<div id="root"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
15
index.tsx
|
|
@ -1,15 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import ReactDOM from 'react-dom/client';
|
|
||||||
import App from './App';
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
|
|
@ -1,465 +0,0 @@
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { UserRole, EventData, EventStatus, EventType } from '../types';
|
|
||||||
import { EventCard } from '../components/EventCard';
|
|
||||||
import { EventForm } from '../components/EventForm';
|
|
||||||
import { Button } from '../components/Button';
|
|
||||||
import { PlusCircle, Search, CheckCircle, Clock, Upload, Edit, Users, Map, Image as ImageIcon } from 'lucide-react';
|
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
|
||||||
import { useData } from '../contexts/DataContext';
|
|
||||||
import { STATUS_COLORS } from '../constants';
|
|
||||||
|
|
||||||
interface DashboardProps {
|
|
||||||
initialView?: 'list' | 'create' | 'uploads';
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Dashboard: React.FC<DashboardProps> = ({ initialView = 'list' }) => {
|
|
||||||
const { user } = useAuth();
|
|
||||||
const { events, getEventsByRole, addEvent, updateEventStatus, assignPhotographer, addAttachment } = useData();
|
|
||||||
const [view, setView] = useState<'list' | 'create' | 'edit' | 'details' | 'uploads'>(initialView);
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
const [selectedEvent, setSelectedEvent] = useState<EventData | null>(null);
|
|
||||||
const [activeFilter, setActiveFilter] = useState<string>('all');
|
|
||||||
|
|
||||||
// Reset view when initialView prop changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialView) {
|
|
||||||
setView(initialView);
|
|
||||||
if (initialView === 'create') setSelectedEvent(null);
|
|
||||||
}
|
|
||||||
}, [initialView]);
|
|
||||||
|
|
||||||
// Guard Clause for basic security
|
|
||||||
if (!user) return <div className="p-10 text-center">Acesso Negado. Faça login.</div>;
|
|
||||||
|
|
||||||
const myEvents = getEventsByRole(user.id, user.role);
|
|
||||||
|
|
||||||
// Filter Logic
|
|
||||||
const filteredEvents = myEvents.filter(e => {
|
|
||||||
const matchesSearch = e.name.toLowerCase().includes(searchTerm.toLowerCase());
|
|
||||||
const matchesStatus = activeFilter === 'all' ||
|
|
||||||
(activeFilter === 'pending' && e.status === EventStatus.PENDING_APPROVAL) ||
|
|
||||||
(activeFilter === 'active' && e.status !== EventStatus.ARCHIVED && e.status !== EventStatus.PENDING_APPROVAL);
|
|
||||||
return matchesSearch && matchesStatus;
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSaveEvent = (data: any) => {
|
|
||||||
const isClient = user.role === UserRole.EVENT_OWNER;
|
|
||||||
|
|
||||||
if (view === 'edit' && selectedEvent) {
|
|
||||||
const updatedEvent = { ...selectedEvent, ...data };
|
|
||||||
console.log("Updated", updatedEvent);
|
|
||||||
setSelectedEvent(updatedEvent);
|
|
||||||
setView('details');
|
|
||||||
} else {
|
|
||||||
const initialStatus = isClient ? EventStatus.PENDING_APPROVAL : EventStatus.PLANNING;
|
|
||||||
const newEvent: EventData = {
|
|
||||||
...data,
|
|
||||||
id: Math.random().toString(36).substr(2, 9),
|
|
||||||
status: initialStatus,
|
|
||||||
checklist: [],
|
|
||||||
attachments: [],
|
|
||||||
ownerId: isClient ? user.id : 'unknown',
|
|
||||||
photographerIds: []
|
|
||||||
};
|
|
||||||
addEvent(newEvent);
|
|
||||||
setView('list');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleApprove = (e: React.MouseEvent, eventId: string) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
updateEventStatus(eventId, EventStatus.CONFIRMED);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenMaps = () => {
|
|
||||||
if (!selectedEvent) return;
|
|
||||||
if (selectedEvent.address.mapLink) {
|
|
||||||
window.open(selectedEvent.address.mapLink, '_blank');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { street, number, city, state } = selectedEvent.address;
|
|
||||||
const query = encodeURIComponent(`${street}, ${number}, ${city} - ${state}`);
|
|
||||||
window.open(`https://www.google.com/maps/search/?api=1&query=${query}`, '_blank');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleManageTeam = () => {
|
|
||||||
if (!selectedEvent) return;
|
|
||||||
const newId = window.prompt("ID do Fotógrafo para adicionar (ex: photographer-1):", "photographer-1");
|
|
||||||
if (newId) {
|
|
||||||
assignPhotographer(selectedEvent.id, newId);
|
|
||||||
alert("Fotógrafo atribuído com sucesso!");
|
|
||||||
const updated = events.find(e => e.id === selectedEvent.id);
|
|
||||||
if (updated) setSelectedEvent(updated);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUploadPhoto = () => {
|
|
||||||
if (!selectedEvent) return;
|
|
||||||
// Mock Upload Action
|
|
||||||
const newPhoto = {
|
|
||||||
name: `Foto_${Date.now()}.jpg`,
|
|
||||||
size: '3.5MB',
|
|
||||||
type: 'image/jpeg',
|
|
||||||
url: `https://picsum.photos/id/${Math.floor(Math.random() * 100)}/400/400`
|
|
||||||
};
|
|
||||||
addAttachment(selectedEvent.id, newPhoto);
|
|
||||||
// Force refresh of selectedEvent state from context source
|
|
||||||
const updated = events.find(e => e.id === selectedEvent.id);
|
|
||||||
if (updated) {
|
|
||||||
// manually inject the new attachment for immediate UI feedback if context isn't enough
|
|
||||||
setSelectedEvent({...updated, attachments: [...updated.attachments, newPhoto]});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- RENDERS PER ROLE ---
|
|
||||||
|
|
||||||
const renderRoleSpecificHeader = () => {
|
|
||||||
if (user.role === UserRole.EVENT_OWNER) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-serif font-bold text-brand-black">Meus Eventos</h1>
|
|
||||||
<p className="text-gray-500 mt-1">Acompanhe seus eventos ou solicite novos orçamentos.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (user.role === UserRole.PHOTOGRAPHER) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-serif font-bold text-brand-black">Eventos Designados</h1>
|
|
||||||
<p className="text-gray-500 mt-1">Gerencie seus trabalhos e realize uploads.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-serif font-bold text-brand-black">Gestão Geral</h1>
|
|
||||||
<p className="text-gray-500 mt-1">Controle total de eventos, aprovações e equipes.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderRoleSpecificActions = () => {
|
|
||||||
if (user.role === UserRole.PHOTOGRAPHER) return null;
|
|
||||||
|
|
||||||
const label = user.role === UserRole.EVENT_OWNER ? "Solicitar Novo Evento" : "Novo Evento";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button onClick={() => setView('create')} className="shadow-lg">
|
|
||||||
<PlusCircle className="mr-2 h-5 w-5" /> {label}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderAdminActions = (event: EventData) => {
|
|
||||||
if (user.role !== UserRole.BUSINESS_OWNER && user.role !== UserRole.SUPERADMIN) return null;
|
|
||||||
|
|
||||||
if (event.status === EventStatus.PENDING_APPROVAL) {
|
|
||||||
return (
|
|
||||||
<div className="absolute top-3 left-3 flex space-x-2 z-10">
|
|
||||||
<button
|
|
||||||
onClick={(e) => handleApprove(e, event.id)}
|
|
||||||
className="bg-green-500 text-white px-3 py-1 rounded-sm text-xs font-bold shadow hover:bg-green-600 transition-colors flex items-center"
|
|
||||||
>
|
|
||||||
<CheckCircle size={12} className="mr-1" /> APROVAR
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- MAIN RENDER ---
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-white pt-24 pb-12 px-4 sm:px-6 lg:px-8">
|
|
||||||
<div className="max-w-7xl mx-auto">
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
{view === 'list' && (
|
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between mb-8 gap-4 fade-in">
|
|
||||||
{renderRoleSpecificHeader()}
|
|
||||||
{renderRoleSpecificActions()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Content Switcher */}
|
|
||||||
{view === 'list' && (
|
|
||||||
<div className="space-y-6 fade-in">
|
|
||||||
{/* Filters Bar */}
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 items-center justify-between bg-gray-50 p-3 rounded-lg border border-gray-100">
|
|
||||||
<div className="relative flex-1 w-full">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Buscar evento..."
|
|
||||||
className="w-full pl-10 pr-4 py-2 bg-white border border-gray-200 rounded-sm focus:outline-none focus:border-brand-gold text-sm"
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(user.role === UserRole.BUSINESS_OWNER || user.role === UserRole.SUPERADMIN) && (
|
|
||||||
<div className="flex space-x-2 bg-white p-1 rounded border border-gray-200">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveFilter('all')}
|
|
||||||
className={`px-3 py-1 text-xs font-medium rounded-sm ${activeFilter === 'all' ? 'bg-brand-black text-white' : 'text-gray-600 hover:bg-gray-100'}`}
|
|
||||||
>
|
|
||||||
Todos
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveFilter('pending')}
|
|
||||||
className={`px-3 py-1 text-xs font-medium rounded-sm flex items-center ${activeFilter === 'pending' ? 'bg-brand-gold text-white' : 'text-gray-600 hover:bg-gray-100'}`}
|
|
||||||
>
|
|
||||||
<Clock size={12} className="mr-1"/> Pendentes
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Grid */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
|
||||||
{filteredEvents.map(event => (
|
|
||||||
<div key={event.id} className="relative group">
|
|
||||||
{renderAdminActions(event)}
|
|
||||||
<EventCard
|
|
||||||
event={event}
|
|
||||||
onClick={() => { setSelectedEvent(event); setView('details'); }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filteredEvents.length === 0 && (
|
|
||||||
<div className="text-center py-20 bg-gray-50 rounded-lg border border-dashed border-gray-200">
|
|
||||||
<p className="text-gray-500 mb-4">Nenhum evento encontrado com os filtros atuais.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(view === 'create' || view === 'edit') && (
|
|
||||||
<EventForm
|
|
||||||
onCancel={() => setView(view === 'edit' ? 'details' : 'list')}
|
|
||||||
onSubmit={handleSaveEvent}
|
|
||||||
initialData={view === 'edit' ? selectedEvent : undefined}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{view === 'details' && selectedEvent && (
|
|
||||||
<div className="fade-in">
|
|
||||||
<Button variant="ghost" onClick={() => setView('list')} className="mb-4 pl-0">
|
|
||||||
← Voltar para lista
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Status Banner */}
|
|
||||||
{selectedEvent.status === EventStatus.PENDING_APPROVAL && user.role === UserRole.EVENT_OWNER && (
|
|
||||||
<div className="bg-yellow-50 border border-yellow-200 text-yellow-800 p-4 rounded-lg mb-6 flex items-start">
|
|
||||||
<Clock className="mr-3 flex-shrink-0" />
|
|
||||||
<div>
|
|
||||||
<h4 className="font-bold">Solicitação em Análise</h4>
|
|
||||||
<p className="text-sm mt-1">Seu evento foi enviado e está aguardando aprovação da equipe Photum.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="bg-white border rounded-lg overflow-hidden shadow-sm">
|
|
||||||
<div className="h-64 w-full relative">
|
|
||||||
<img src={selectedEvent.coverImage} className="w-full h-full object-cover" alt="Cover" />
|
|
||||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
|
||||||
<h1 className="text-4xl font-serif text-white font-bold text-center px-4 drop-shadow-lg">{selectedEvent.name}</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-8">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
|
||||||
<div className="col-span-2 space-y-8">
|
|
||||||
{/* Actions Toolbar */}
|
|
||||||
<div className="flex flex-wrap gap-3 border-b pb-4">
|
|
||||||
{user.role === UserRole.PHOTOGRAPHER && (
|
|
||||||
<Button onClick={() => setView('uploads')} className="flex items-center">
|
|
||||||
<Upload size={16} className="mr-2" /> Gerenciar Uploads
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{(user.role === UserRole.BUSINESS_OWNER || user.role === UserRole.SUPERADMIN) && (
|
|
||||||
<>
|
|
||||||
<Button variant="outline" onClick={() => setView('edit')}>
|
|
||||||
<Edit size={16} className="mr-2"/> Editar Detalhes
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" onClick={handleManageTeam}>
|
|
||||||
<Users size={16} className="mr-2"/> Gerenciar Equipe
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{user.role === UserRole.EVENT_OWNER && selectedEvent.status !== EventStatus.ARCHIVED && (
|
|
||||||
<Button variant="outline" onClick={() => setView('edit')}>
|
|
||||||
<Edit size={16} className="mr-2"/> Editar Informações
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3 className="text-lg font-bold border-b pb-2 mb-4 text-brand-black">Sobre o Evento</h3>
|
|
||||||
<p className="text-gray-600 leading-relaxed whitespace-pre-wrap">{selectedEvent.briefing || "Sem briefing detalhado."}</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{selectedEvent.contacts.length > 0 && (
|
|
||||||
<section>
|
|
||||||
<h3 className="text-lg font-bold border-b pb-2 mb-4 text-brand-black">Contatos & Responsáveis</h3>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
{selectedEvent.contacts.map((c, i) => (
|
|
||||||
<div key={i} className="bg-gray-50 p-4 rounded-sm border border-gray-100">
|
|
||||||
<p className="font-bold text-sm">{c.name}</p>
|
|
||||||
<p className="text-xs text-brand-gold uppercase tracking-wide">{c.role}</p>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">{c.phone}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-span-1 space-y-6">
|
|
||||||
<div className={`p-6 rounded-sm border ${STATUS_COLORS[selectedEvent.status]} bg-opacity-10`}>
|
|
||||||
<h4 className="font-bold uppercase tracking-widest text-xs mb-2 opacity-70">Status Atual</h4>
|
|
||||||
<p className="text-xl font-serif font-bold">{selectedEvent.status}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border p-6 rounded-sm bg-gray-50">
|
|
||||||
<h4 className="font-bold uppercase tracking-widest text-xs mb-4 text-gray-400">Localização</h4>
|
|
||||||
<p className="font-medium text-lg">{selectedEvent.address.street}, {selectedEvent.address.number}</p>
|
|
||||||
<p className="text-gray-500 mb-4">{selectedEvent.address.city} - {selectedEvent.address.state}</p>
|
|
||||||
|
|
||||||
{selectedEvent.address.mapLink ? (
|
|
||||||
<Button variant="secondary" size="sm" className="w-full" onClick={handleOpenMaps}>
|
|
||||||
<Map size={16} className="mr-2"/> Abrir no Google Maps
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button variant="outline" size="sm" className="w-full bg-white" onClick={handleOpenMaps}>
|
|
||||||
<Map size={16} className="mr-2"/> Buscar no Maps
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(selectedEvent.photographerIds.length > 0 || user.role === UserRole.BUSINESS_OWNER) && (
|
|
||||||
<div className="border p-6 rounded-sm">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h4 className="font-bold uppercase tracking-widest text-xs text-gray-400">Equipe Designada</h4>
|
|
||||||
{(user.role === UserRole.BUSINESS_OWNER || user.role === UserRole.SUPERADMIN) && (
|
|
||||||
<button onClick={handleManageTeam} className="text-brand-gold hover:text-brand-black"><PlusCircle size={16}/></button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedEvent.photographerIds.length > 0 ? (
|
|
||||||
<div className="flex -space-x-2">
|
|
||||||
{selectedEvent.photographerIds.map((id, idx) => (
|
|
||||||
<div key={id} className="w-10 h-10 rounded-full border-2 border-white bg-gray-300"
|
|
||||||
style={{backgroundImage: `url(https://i.pravatar.cc/100?u=${id})`, backgroundSize: 'cover'}}
|
|
||||||
title={id}
|
|
||||||
></div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-gray-400 italic">Nenhum profissional atribuído.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{view === 'uploads' && (
|
|
||||||
<div className="fade-in">
|
|
||||||
{/* Check if user came from 'details' of a selected event OR came from Navbar */}
|
|
||||||
{selectedEvent ? (
|
|
||||||
<div>
|
|
||||||
<Button variant="ghost" onClick={() => setView('details')} className="mb-4 pl-0">
|
|
||||||
← Voltar para Detalhes
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-serif text-brand-black">Galeria de Evento: {selectedEvent.name}</h2>
|
|
||||||
<p className="text-gray-500 text-sm">Gerencie as fotos e faça novos uploads.</p>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" onClick={() => setSelectedEvent(null)}>
|
|
||||||
Trocar Evento
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Drag and Drop Area */}
|
|
||||||
<div
|
|
||||||
className="border-2 border-dashed border-gray-300 rounded-lg p-12 text-center bg-gray-50 hover:bg-gray-100 transition-colors cursor-pointer group mb-8"
|
|
||||||
onClick={handleUploadPhoto}
|
|
||||||
>
|
|
||||||
<Upload size={48} className="mx-auto text-gray-400 mb-4 group-hover:text-brand-gold transition-colors" />
|
|
||||||
<h3 className="text-xl font-medium text-gray-700 mb-2">Adicionar Novas Fotos</h3>
|
|
||||||
<p className="text-gray-500">Clique aqui para simular o upload de uma nova imagem</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Gallery Grid */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="font-bold text-lg flex items-center">
|
|
||||||
<ImageIcon className="mr-2 text-brand-gold" size={20}/>
|
|
||||||
Fotos do Evento ({selectedEvent.attachments.filter(a => a.type.startsWith('image')).length})
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{selectedEvent.attachments.length > 0 ? (
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
|
||||||
{selectedEvent.attachments.map((file, idx) => (
|
|
||||||
<div key={idx} className="relative group aspect-square bg-gray-100 rounded overflow-hidden shadow-sm hover:shadow-md transition-all">
|
|
||||||
{file.url ? (
|
|
||||||
<img src={file.url} alt={file.name} className="w-full h-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<div className="w-full h-full flex items-center justify-center text-gray-400">
|
|
||||||
<ImageIcon size={32}/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-end p-2">
|
|
||||||
<span className="text-white text-xs truncate w-full">{file.name}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-10 bg-white border rounded">
|
|
||||||
<p className="text-gray-400">Nenhuma foto carregada ainda.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// Logic when clicking "Meus Uploads" in navbar: Select an Event first
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-serif text-brand-black mb-6">Selecione um evento para gerenciar uploads</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
{myEvents.map(event => (
|
|
||||||
<div
|
|
||||||
key={event.id}
|
|
||||||
className="bg-white border hover:border-brand-gold rounded-lg p-6 cursor-pointer hover:shadow-lg transition-all"
|
|
||||||
onClick={() => setSelectedEvent(event)}
|
|
||||||
>
|
|
||||||
<h3 className="font-bold text-lg mb-2">{event.name}</h3>
|
|
||||||
<p className="text-gray-500 text-sm mb-4">{new Date(event.date).toLocaleDateString()}</p>
|
|
||||||
<div className="flex items-center text-brand-gold text-sm font-medium">
|
|
||||||
<ImageIcon size={16} className="mr-2"/>
|
|
||||||
{event.attachments.length} arquivos
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{myEvents.length === 0 && (
|
|
||||||
<p className="text-gray-500 col-span-3 text-center py-10">Você não possui eventos designados no momento.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
112
pages/Home.tsx
|
|
@ -1,112 +0,0 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { Button } from '../components/Button';
|
|
||||||
import { Camera, Heart, Shield, Star } from 'lucide-react';
|
|
||||||
|
|
||||||
const HERO_IMAGES = [
|
|
||||||
"https://images.unsplash.com/photo-1511285560982-1351cdeb9821?ixlib=rb-1.2.1&auto=format&fit=crop&w=1920&q=80",
|
|
||||||
"https://images.unsplash.com/photo-1519741497674-611481863552?ixlib=rb-1.2.1&auto=format&fit=crop&w=1920&q=80",
|
|
||||||
"https://images.unsplash.com/photo-1472653431158-6364773b2710?ixlib=rb-1.2.1&auto=format&fit=crop&w=1920&q=80"
|
|
||||||
];
|
|
||||||
|
|
||||||
interface HomeProps {
|
|
||||||
onEnter: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Home: React.FC<HomeProps> = ({ onEnter }) => {
|
|
||||||
const [currentSlide, setCurrentSlide] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
setCurrentSlide((prev) => (prev + 1) % HERO_IMAGES.length);
|
|
||||||
}, 5000);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-white">
|
|
||||||
{/* Hero Section */}
|
|
||||||
<div className="relative h-screen w-full overflow-hidden">
|
|
||||||
{HERO_IMAGES.map((img, idx) => (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className={`absolute inset-0 transition-opacity duration-1000 ease-in-out ${idx === currentSlide ? 'opacity-100' : 'opacity-0'}`}
|
|
||||||
>
|
|
||||||
<img src={img} alt="Hero" className="w-full h-full object-cover" />
|
|
||||||
<div className="absolute inset-0 bg-black/40"></div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-center px-4">
|
|
||||||
<div className="max-w-4xl space-y-6 slide-up">
|
|
||||||
<h1 className="text-4xl md:text-6xl lg:text-7xl font-serif text-white leading-tight">
|
|
||||||
Eternizando Momentos <br />
|
|
||||||
<span className="text-brand-gold italic">Únicos</span>
|
|
||||||
</h1>
|
|
||||||
<p className="text-lg md:text-xl text-gray-200 font-light max-w-2xl mx-auto tracking-wide">
|
|
||||||
Gestão completa para eventos inesquecíveis. Do planejamento à entrega do álbum perfeito.
|
|
||||||
</p>
|
|
||||||
<div className="pt-8 space-x-4">
|
|
||||||
<Button size="lg" variant="secondary" onClick={onEnter}>
|
|
||||||
Área do Cliente
|
|
||||||
</Button>
|
|
||||||
<Button size="lg" variant="outline" className="border-white text-white hover:bg-white hover:text-black">
|
|
||||||
Ver Portfólio
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Carousel Dots */}
|
|
||||||
<div className="absolute bottom-10 left-0 right-0 flex justify-center space-x-3">
|
|
||||||
{HERO_IMAGES.map((_, idx) => (
|
|
||||||
<button
|
|
||||||
key={idx}
|
|
||||||
className={`w-2 h-2 rounded-full transition-all ${idx === currentSlide ? 'bg-brand-gold w-8' : 'bg-white/50'}`}
|
|
||||||
onClick={() => setCurrentSlide(idx)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Features Section */}
|
|
||||||
<section className="py-20 bg-white">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div className="text-center mb-16">
|
|
||||||
<h2 className="text-sm font-bold tracking-widest text-brand-gold uppercase mb-2">Por que nós?</h2>
|
|
||||||
<h3 className="text-3xl md:text-4xl font-serif text-brand-black">Excelência em cada detalhe</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-12">
|
|
||||||
{[
|
|
||||||
{ icon: <Camera size={32}/>, title: "Qualidade Impecável", desc: "Equipamentos de última geração e profissionais premiados." },
|
|
||||||
{ icon: <Shield size={32}/>, title: "Segurança Total", desc: "Backup duplo em nuvem e contratos transparentes." },
|
|
||||||
{ icon: <Heart size={32}/>, title: "Atendimento Humanizado", desc: "Entendemos que seu evento é um sonho a ser realizado." }
|
|
||||||
].map((feature, idx) => (
|
|
||||||
<div key={idx} className="text-center group p-6 rounded-lg hover:bg-gray-50 transition-colors">
|
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gray-100 text-brand-black mb-6 group-hover:bg-brand-gold group-hover:text-white transition-colors">
|
|
||||||
{feature.icon}
|
|
||||||
</div>
|
|
||||||
<h4 className="text-xl font-medium mb-3">{feature.title}</h4>
|
|
||||||
<p className="text-gray-500 font-light leading-relaxed">{feature.desc}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Testimonials */}
|
|
||||||
<section className="py-20 bg-brand-black text-white">
|
|
||||||
<div className="max-w-4xl mx-auto px-4 text-center">
|
|
||||||
<Star className="text-brand-gold mx-auto mb-6" size={40} fill="#c5a059" />
|
|
||||||
<blockquote className="text-2xl md:text-3xl font-serif italic leading-relaxed mb-8">
|
|
||||||
"A equipe do Photum superou todas as expectativas. O sistema de acompanhamento nos deixou tranquilos durante todo o processo e as fotos ficaram incríveis."
|
|
||||||
</blockquote>
|
|
||||||
<cite className="not-italic">
|
|
||||||
<span className="font-bold block text-brand-gold">Mariana & Pedro</span>
|
|
||||||
<span className="text-sm text-gray-400 uppercase tracking-widest">Casamento em Campos do Jordão</span>
|
|
||||||
</cite>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
111
pages/Login.tsx
|
|
@ -1,111 +0,0 @@
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
|
||||||
import { Button } from '../components/Button';
|
|
||||||
import { Input } from '../components/Input';
|
|
||||||
import { UserRole } from '../types';
|
|
||||||
|
|
||||||
export const Login: React.FC = () => {
|
|
||||||
const { login, availableUsers } = useAuth();
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
const handleLogin = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsLoading(true);
|
|
||||||
setError('');
|
|
||||||
|
|
||||||
const success = await login(email);
|
|
||||||
if (!success) {
|
|
||||||
setError('Usuário não encontrado. Tente um dos e-mails de demonstração.');
|
|
||||||
}
|
|
||||||
setIsLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const fillCredentials = (userEmail: string) => {
|
|
||||||
setEmail(userEmail);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRoleLabel = (role: UserRole) => {
|
|
||||||
switch(role) {
|
|
||||||
case UserRole.SUPERADMIN: return "Superadmin";
|
|
||||||
case UserRole.BUSINESS_OWNER: return "Empresa";
|
|
||||||
case UserRole.PHOTOGRAPHER: return "Fotógrafo";
|
|
||||||
case UserRole.EVENT_OWNER: return "Cliente";
|
|
||||||
default: return role;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex bg-white">
|
|
||||||
{/* Left Side - Image */}
|
|
||||||
<div className="hidden lg:block lg:w-1/2 relative overflow-hidden">
|
|
||||||
<img
|
|
||||||
src="https://images.unsplash.com/photo-1519741497674-611481863552?ixlib=rb-1.2.1&auto=format&fit=crop&w=1920&q=80"
|
|
||||||
alt="Photum Login"
|
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-brand-black/40 flex items-center justify-center">
|
|
||||||
<div className="text-center text-white p-12">
|
|
||||||
<h1 className="text-5xl font-serif font-bold mb-4">Photum Manager</h1>
|
|
||||||
<p className="text-xl font-light tracking-wide max-w-md mx-auto">Gestão de eventos premium para quem não abre mão da excelência.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Side - Form */}
|
|
||||||
<div className="w-full lg:w-1/2 flex items-center justify-center p-8 lg:p-16">
|
|
||||||
<div className="max-w-md w-full space-y-8 fade-in">
|
|
||||||
<div className="text-center lg:text-left">
|
|
||||||
<span className="text-brand-gold font-bold tracking-widest uppercase text-sm">Bem-vindo de volta</span>
|
|
||||||
<h2 className="mt-2 text-3xl font-serif font-bold text-gray-900">Acesse sua conta</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form className="mt-8 space-y-6" onSubmit={handleLogin}>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Input
|
|
||||||
label="E-mail Corporativo ou Pessoal"
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
placeholder="nome@exemplo.com"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
error={error}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Senha"
|
|
||||||
type="password"
|
|
||||||
placeholder="••••••••"
|
|
||||||
readOnly
|
|
||||||
className="bg-gray-50 cursor-not-allowed"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" className="w-full" size="lg" isLoading={isLoading}>
|
|
||||||
Entrar no Sistema
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Demo Users Quick Select */}
|
|
||||||
<div className="mt-10 pt-10 border-t border-gray-100">
|
|
||||||
<p className="text-xs text-gray-400 uppercase tracking-widest mb-4 text-center">Usuários de Demonstração (Clique para preencher)</p>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{availableUsers.map(user => (
|
|
||||||
<button
|
|
||||||
key={user.id}
|
|
||||||
onClick={() => fillCredentials(user.email)}
|
|
||||||
className="flex flex-col items-start p-3 border border-gray-200 rounded-sm hover:border-brand-gold hover:bg-gray-50 transition-all text-left group"
|
|
||||||
>
|
|
||||||
<span className="text-xs font-bold text-gray-700 group-hover:text-brand-black">{user.name}</span>
|
|
||||||
<span className="text-[10px] uppercase tracking-wide text-brand-gold mt-1">{getRoleLabel(user.role)}</span>
|
|
||||||
<span className="text-[10px] text-gray-400 mt-0.5 truncate w-full">{user.email}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
import { GoogleGenAI } from "@google/genai";
|
|
||||||
|
|
||||||
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
|
|
||||||
|
|
||||||
export interface GeoResult {
|
|
||||||
street: string;
|
|
||||||
number: string;
|
|
||||||
city: string;
|
|
||||||
state: string;
|
|
||||||
zip: string;
|
|
||||||
description: string;
|
|
||||||
mapLink?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const searchLocationWithGemini = async (query: string): Promise<GeoResult[]> => {
|
|
||||||
if (!query || query.length < 3) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Attempt to get user location for better context
|
|
||||||
let userLocation: { latitude: number; longitude: number } | undefined;
|
|
||||||
try {
|
|
||||||
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
|
|
||||||
navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 2000 });
|
|
||||||
});
|
|
||||||
userLocation = {
|
|
||||||
latitude: position.coords.latitude,
|
|
||||||
longitude: position.coords.longitude
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore geolocation errors, proceed without it
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await ai.models.generateContent({
|
|
||||||
model: 'gemini-2.5-flash',
|
|
||||||
contents: `Find the specific address details for the location query: "${query}".
|
|
||||||
|
|
||||||
You MUST return the address components in the following format (one per line):
|
|
||||||
Description: [A concise formatted address]
|
|
||||||
Street: [Street Name]
|
|
||||||
Number: [Street Number, if available]
|
|
||||||
City: [City Name]
|
|
||||||
State: [State Code, e.g., SP, RJ]
|
|
||||||
Zip: [Postal Code]
|
|
||||||
|
|
||||||
If a specific component is not found, leave it empty after the colon.`,
|
|
||||||
config: {
|
|
||||||
tools: [{ googleMaps: {} }],
|
|
||||||
toolConfig: userLocation ? {
|
|
||||||
retrievalConfig: {
|
|
||||||
latLng: {
|
|
||||||
latitude: userLocation.latitude,
|
|
||||||
longitude: userLocation.longitude
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} : undefined
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const text = response.text || "";
|
|
||||||
|
|
||||||
// Helper parser
|
|
||||||
const parseField = (key: string) => {
|
|
||||||
const regex = new RegExp(`${key}:\\s*(.*)`, 'i');
|
|
||||||
const match = text.match(regex);
|
|
||||||
return match ? match[1].trim() : '';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Extract Google Maps URI from grounding metadata
|
|
||||||
let mapUri = '';
|
|
||||||
const chunks = response.candidates?.[0]?.groundingMetadata?.groundingChunks || [];
|
|
||||||
|
|
||||||
// Look for the Web URI in the chunks (Google Maps grounding often returns this)
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
if (chunk.web?.uri && (chunk.web.uri.includes('google.com/maps') || chunk.web.uri.includes('maps.google.com'))) {
|
|
||||||
mapUri = chunk.web.uri;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: GeoResult = {
|
|
||||||
street: parseField('Street'),
|
|
||||||
number: parseField('Number'),
|
|
||||||
city: parseField('City'),
|
|
||||||
state: parseField('State'),
|
|
||||||
zip: parseField('Zip'),
|
|
||||||
description: parseField('Description') || text.split('\n')[0], // Fallback to first line
|
|
||||||
mapLink: mapUri
|
|
||||||
};
|
|
||||||
|
|
||||||
// Filter out bad results (empty city/state usually means it failed to identify a place)
|
|
||||||
if (!result.city && !result.street) return [];
|
|
||||||
|
|
||||||
return [result];
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Gemini Search Error:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||