Merge pull request #1 from rede5/dev

Dev
This commit is contained in:
João Vitor 2025-12-03 11:17:47 -03:00 committed by GitHub
commit 242938a14a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 10433 additions and 1798 deletions

114
App.tsx
View file

@ -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>&copy; 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;

View file

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

View 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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

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

View file

266
frontend/App.tsx Normal file
View 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>&copy; 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
View 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
View 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** ✨📸

View file

@ -2,23 +2,23 @@ import React from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
size?: 'sm' | 'md' | 'lg';
size?: 'sm' | 'md' | 'lg' | 'xl';
isLoading?: boolean;
}
export const Button: React.FC<ButtonProps> = ({
children,
variant = 'primary',
size = 'md',
export const Button: React.FC<ButtonProps> = ({
children,
variant = 'primary',
size = 'md',
isLoading,
className = '',
...props
className = '',
...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 variants = {
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",
ghost: "text-brand-black hover:bg-gray-100 hover:text-gray-900"
};
@ -26,11 +26,12 @@ export const Button: React.FC<ButtonProps> = ({
const sizes = {
sm: "text-xs px-3 py-1.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 (
<button
<button
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
disabled={isLoading || props.disabled}
{...props}

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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
View 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
View 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>
);

View file

@ -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.",
"requestFramePermissions": [
"geolocation"

2924
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
{
"name": "photummanager",
"name": "photumformaturas",
"private": true,
"version": "0.0.0",
"type": "module",
@ -9,10 +9,13 @@
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.0",
"@google/genai": "^1.30.0",
"@types/mapbox-gl": "^3.4.1",
"lucide-react": "^0.554.0",
"mapbox-gl": "^3.16.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"@google/genai": "^1.30.0"
"react-router-dom": "^7.9.6"
},
"devDependencies": {
"@types/node": "^22.14.0",

583
frontend/pages/Calendar.tsx Normal file
View 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>
);
};

View 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
View 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
View 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>
);
};

View 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
View 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 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
View 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>
);
};

View 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
View 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">
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
View 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
View 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>
);
};

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 711 KiB

BIN
frontend/public/HOME_14.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
frontend/public/HOME_15.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
frontend/public/HOME_16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 933 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
frontend/public/HOME_17.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
frontend/public/banner2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB

BIN
frontend/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
frontend/public/logo23.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
frontend/public/logofav.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View 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 [];
};

View 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;
}
}

View file

@ -1,26 +1,25 @@
export enum UserRole {
SUPERADMIN = 'SUPERADMIN',
BUSINESS_OWNER = 'BUSINESS_OWNER',
EVENT_OWNER = 'EVENT_OWNER',
PHOTOGRAPHER = 'PHOTOGRAPHER'
SUPERADMIN = "SUPERADMIN",
BUSINESS_OWNER = "BUSINESS_OWNER",
EVENT_OWNER = "EVENT_OWNER",
PHOTOGRAPHER = "PHOTOGRAPHER",
}
export enum EventStatus {
PENDING_APPROVAL = 'Aguardando Aprovação', // Novo status para clientes
PLANNING = 'Em Planejamento',
CONFIRMED = 'Confirmado',
IN_PROGRESS = 'Em Execução',
DELIVERED = 'Entregue',
ARCHIVED = 'Arquivado'
PENDING_APPROVAL = "Aguardando Aprovação", // Novo status para clientes
PLANNING = "Em Planejamento",
CONFIRMED = "Confirmado",
IN_PROGRESS = "Em Execução",
DELIVERED = "Entregue",
ARCHIVED = "Arquivado",
}
export enum EventType {
WEDDING = 'Casamento',
CORPORATE = 'Corporativo',
BIRTHDAY = 'Aniversário',
DEBUTANTE = 'Debutante',
OTHER = 'Outro'
WEDDING = "Casamento",
CORPORATE = "Corporativo",
BIRTHDAY = "Aniversário",
DEBUTANTE = "Debutante",
OTHER = "Outro",
}
export interface User {
@ -29,6 +28,19 @@ export interface User {
email: string;
role: UserRole;
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 {
@ -57,13 +69,6 @@ export interface ChecklistItem {
required: boolean;
}
export interface Attachment {
name: string;
size: string;
type: string;
url?: string; // Added URL for gallery display
}
export interface EventData {
id: string;
name: string;
@ -76,7 +81,7 @@ export interface EventData {
checklist: ChecklistItem[];
briefing: string;
coverImage: string;
attachments: Attachment[];
ownerId: string; // ID do cliente dono do evento
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
View file

@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_MAPBOX_TOKEN: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View file

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

View file

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

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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 [];
}
};