114
App.tsx
|
|
@ -1,114 +0,0 @@
|
|||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Navbar } from './components/Navbar';
|
||||
import { Home } from './pages/Home';
|
||||
import { Dashboard } from './pages/Dashboard';
|
||||
import { Login } from './pages/Login';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import { DataProvider } from './contexts/DataContext';
|
||||
import { Construction } from 'lucide-react'; // Placeholder icon
|
||||
|
||||
const AppContent: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const [currentPage, setCurrentPage] = useState('home');
|
||||
|
||||
useEffect(() => {
|
||||
if (user && currentPage === 'login') {
|
||||
setCurrentPage('dashboard');
|
||||
}
|
||||
}, [user, currentPage]);
|
||||
|
||||
// Simple Router Logic
|
||||
const renderPage = () => {
|
||||
if (currentPage === 'home') return <Home onEnter={() => setCurrentPage(user ? 'dashboard' : 'login')} />;
|
||||
if (currentPage === 'login') return user ? <Dashboard /> : <Login />;
|
||||
|
||||
// Protected Routes Check
|
||||
if (!user) return <Login />;
|
||||
|
||||
switch (currentPage) {
|
||||
case 'dashboard':
|
||||
case 'events':
|
||||
return <Dashboard initialView="list" />;
|
||||
|
||||
case 'request-event':
|
||||
return <Dashboard initialView="create" />;
|
||||
|
||||
case 'uploads':
|
||||
return <Dashboard initialView="uploads" />;
|
||||
|
||||
// Placeholder routes for future implementation
|
||||
case 'team':
|
||||
case 'finance':
|
||||
case 'settings':
|
||||
case 'albums':
|
||||
case 'calendar':
|
||||
return (
|
||||
<div className="min-h-screen bg-white pt-32 px-4 text-center fade-in">
|
||||
<div className="max-w-md mx-auto bg-gray-50 p-12 rounded-lg border border-gray-100 shadow-sm">
|
||||
<div className="mx-auto w-16 h-16 bg-gray-200 rounded-full flex items-center justify-center mb-6 text-gray-400">
|
||||
<Construction size={32} />
|
||||
</div>
|
||||
<h2 className="text-2xl font-serif font-bold mb-3 text-brand-black capitalize">
|
||||
{currentPage === 'team' ? 'Equipe & Fotógrafos' :
|
||||
currentPage === 'finance' ? 'Financeiro' :
|
||||
currentPage === 'calendar' ? 'Agenda' :
|
||||
currentPage}
|
||||
</h2>
|
||||
<p className="text-gray-500 mb-8 leading-relaxed">
|
||||
Esta funcionalidade está em desenvolvimento e estará disponível em breve no seu painel.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setCurrentPage('dashboard')}
|
||||
className="px-6 py-2 bg-brand-black text-white rounded-sm hover:bg-gray-800 transition-colors font-medium text-sm"
|
||||
>
|
||||
Voltar ao Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
// Fallback
|
||||
return <Dashboard initialView="list" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<Navbar
|
||||
onNavigate={setCurrentPage}
|
||||
currentPage={currentPage}
|
||||
/>
|
||||
<main>
|
||||
{renderPage()}
|
||||
</main>
|
||||
|
||||
{/* Footer only on Home */}
|
||||
{currentPage === 'home' && (
|
||||
<footer className="bg-white border-t border-gray-100 py-12">
|
||||
<div className="max-w-7xl mx-auto px-4 flex flex-col md:flex-row justify-between items-center text-sm text-gray-500">
|
||||
<p>© 2024 PhotumManager. Todos os direitos reservados.</p>
|
||||
<div className="flex space-x-6 mt-4 md:mt-0">
|
||||
<a href="#" className="hover:text-brand-black">Política de Privacidade</a>
|
||||
<a href="#" className="hover:text-brand-black">Termos de Uso</a>
|
||||
<a href="#" className="hover:text-brand-black">Instagram</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<DataProvider>
|
||||
<AppContent />
|
||||
</DataProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
20
README.md
|
|
@ -1,20 +0,0 @@
|
|||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||
</div>
|
||||
|
||||
# Run and deploy your AI Studio appp
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/drive/1Rd4siG8Ot2v0r3XhTNfIYrylHVUvYJmm
|
||||
|
||||
## Run Locally
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
0
backend/.gitkeep
Normal file
|
|
@ -1,405 +0,0 @@
|
|||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { EventType, EventStatus, Address } from '../types';
|
||||
import { Input, Select } from './Input';
|
||||
import { Button } from './Button';
|
||||
import { MapPin, Upload, Plus, X, Check, FileText, ExternalLink, Search, CheckCircle } from 'lucide-react';
|
||||
import { searchLocationWithGemini, GeoResult } from '../services/genaiService';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { UserRole } from '../types';
|
||||
|
||||
interface EventFormProps {
|
||||
onCancel: () => void;
|
||||
onSubmit: (data: any) => void;
|
||||
initialData?: any;
|
||||
}
|
||||
|
||||
export const EventForm: React.FC<EventFormProps> = ({ onCancel, onSubmit, initialData }) => {
|
||||
const { user } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState<'details' | 'location' | 'briefing' | 'files'>('details');
|
||||
const [addressQuery, setAddressQuery] = useState('');
|
||||
const [addressResults, setAddressResults] = useState<GeoResult[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
|
||||
// Default State or Initial Data
|
||||
const [formData, setFormData] = useState(initialData || {
|
||||
name: '',
|
||||
date: '',
|
||||
time: '',
|
||||
type: '',
|
||||
status: EventStatus.PLANNING,
|
||||
address: { street: '', number: '', city: '', state: '', zip: '', mapLink: '' } as Address,
|
||||
briefing: '',
|
||||
contacts: [{ name: '', role: '', phone: '' }],
|
||||
files: [] as File[],
|
||||
coverImage: 'https://images.unsplash.com/photo-1511795409834-ef04bbd61622?ixlib=rb-1.2.1&auto=format&fit=crop&w=800&q=80' // Default
|
||||
});
|
||||
|
||||
const isClientRequest = user?.role === UserRole.EVENT_OWNER;
|
||||
const formTitle = initialData
|
||||
? "Editar Evento"
|
||||
: (isClientRequest ? "Solicitar Orçamento/Evento" : "Cadastrar Novo Evento");
|
||||
const submitLabel = initialData ? "Salvar Alterações" : (isClientRequest ? "Enviar Solicitação" : "Criar Evento");
|
||||
|
||||
// Address Autocomplete Logic using Gemini
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(async () => {
|
||||
if (addressQuery.length > 4) { // Increased threshold to avoid spamming API
|
||||
setIsSearching(true);
|
||||
const results = await searchLocationWithGemini(addressQuery);
|
||||
setAddressResults(results);
|
||||
setIsSearching(false);
|
||||
} else {
|
||||
setAddressResults([]);
|
||||
}
|
||||
}, 800); // Increased debounce for API efficiency
|
||||
return () => clearTimeout(timer);
|
||||
}, [addressQuery]);
|
||||
|
||||
const handleAddressSelect = (addr: GeoResult) => {
|
||||
setFormData((prev: any) => ({
|
||||
...prev,
|
||||
address: {
|
||||
street: addr.street,
|
||||
number: addr.number,
|
||||
city: addr.city,
|
||||
state: addr.state,
|
||||
zip: addr.zip,
|
||||
mapLink: addr.mapLink
|
||||
}
|
||||
}));
|
||||
setAddressQuery('');
|
||||
setAddressResults([]);
|
||||
};
|
||||
|
||||
const addContact = () => {
|
||||
setFormData((prev: any) => ({
|
||||
...prev,
|
||||
contacts: [...prev.contacts, { name: '', role: '', phone: '' }]
|
||||
}));
|
||||
};
|
||||
|
||||
const removeContact = (index: number) => {
|
||||
const newContacts = [...formData.contacts];
|
||||
newContacts.splice(index, 1);
|
||||
setFormData((prev: any) => ({ ...prev, contacts: newContacts }));
|
||||
};
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
setFormData((prev: any) => ({
|
||||
...prev,
|
||||
files: [...prev.files, ...Array.from(e.target.files || [])]
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
// Show toast
|
||||
setShowToast(true);
|
||||
// Call original submit after small delay for visual effect or immediately
|
||||
setTimeout(() => {
|
||||
onSubmit(formData);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-xl overflow-hidden max-w-4xl mx-auto border border-gray-100 slide-up relative">
|
||||
|
||||
{/* Success Toast */}
|
||||
{showToast && (
|
||||
<div className="absolute top-4 right-4 z-50 bg-brand-black text-white px-6 py-4 rounded shadow-2xl flex items-center space-x-3 fade-in">
|
||||
<CheckCircle className="text-brand-gold h-6 w-6" />
|
||||
<div>
|
||||
<h4 className="font-bold text-sm">Sucesso!</h4>
|
||||
<p className="text-xs text-gray-300">As informações foram salvas.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form Header */}
|
||||
<div className="bg-gray-50 border-b px-8 py-6 flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-serif text-brand-black">{formTitle}</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{isClientRequest
|
||||
? "Preencha os detalhes do seu sonho. Nossa equipe analisará em breve."
|
||||
: "Preencha as informações técnicas do evento."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
{['details', 'location', 'briefing', 'files'].map((tab, idx) => (
|
||||
<div key={tab} className={`flex flex-col items-center ${activeTab === tab ? 'opacity-100' : 'opacity-40'}`}>
|
||||
<span className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold mb-1 ${activeTab === tab ? 'bg-brand-black text-white' : 'bg-gray-200 text-gray-600'}`}>
|
||||
{idx + 1}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 min-h-[500px]">
|
||||
{/* Sidebar Navigation for Form */}
|
||||
<div className="col-span-1 border-r border-gray-100 bg-gray-50/50 p-4 space-y-2">
|
||||
{[
|
||||
{ id: 'details', label: 'Detalhes & Data' },
|
||||
{ id: 'location', label: 'Localização' },
|
||||
{ id: 'briefing', label: isClientRequest ? 'Seus Desejos' : 'Briefing & Equipe' },
|
||||
{ id: 'files', label: 'Inspirações' }
|
||||
].map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActiveTab(item.id as any)}
|
||||
className={`w-full text-left px-4 py-3 rounded-sm text-sm font-medium transition-colors ${
|
||||
activeTab === item.id
|
||||
? 'bg-white shadow-sm text-brand-gold border-l-4 border-brand-gold'
|
||||
: 'text-gray-500 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Form Content */}
|
||||
<div className="col-span-3 p-8">
|
||||
|
||||
{activeTab === 'details' && (
|
||||
<div className="space-y-6 fade-in">
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<Input
|
||||
label="Nome do Evento (Opcional)"
|
||||
placeholder="Ex: Casamento Silva & Souza"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({...formData, name: e.target.value})}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Data Pretendida"
|
||||
type="date"
|
||||
value={formData.date}
|
||||
onChange={(e) => setFormData({...formData, date: e.target.value})}
|
||||
/>
|
||||
<Input
|
||||
label="Horário Aproximado"
|
||||
type="time"
|
||||
value={formData.time}
|
||||
onChange={(e) => setFormData({...formData, time: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
label="Tipo de Evento"
|
||||
options={Object.values(EventType).map(t => ({ value: t, label: t }))}
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({...formData, type: e.target.value})}
|
||||
/>
|
||||
{/* Cover Image Upload (Basic URL input for now) */}
|
||||
<Input
|
||||
label="URL Imagem de Capa"
|
||||
placeholder="https://..."
|
||||
value={formData.coverImage}
|
||||
onChange={(e) => setFormData({...formData, coverImage: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-8">
|
||||
<Button onClick={() => setActiveTab('location')}>Próximo: Localização</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'location' && (
|
||||
<div className="space-y-6 fade-in">
|
||||
<div className="relative">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1 tracking-wide uppercase text-xs">
|
||||
Busca Google Maps (Powered by Gemini)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-sm focus:outline-none focus:ring-1 focus:ring-brand-gold focus:border-brand-gold transition-colors pr-10"
|
||||
placeholder="Digite o nome do local ou endereço..."
|
||||
value={addressQuery}
|
||||
onChange={(e) => setAddressQuery(e.target.value)}
|
||||
/>
|
||||
<div className="absolute right-3 top-2.5 text-gray-400">
|
||||
{isSearching ? (
|
||||
<div className="animate-spin h-5 w-5 border-2 border-brand-gold rounded-full border-t-transparent"></div>
|
||||
) : (
|
||||
<Search size={20} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{addressResults.length > 0 && (
|
||||
<ul className="absolute z-10 w-full bg-white border mt-1 shadow-lg rounded-sm max-h-64 overflow-y-auto">
|
||||
{addressResults.map((addr, idx) => (
|
||||
<li
|
||||
key={idx}
|
||||
className="px-4 py-3 hover:bg-gray-50 cursor-pointer text-sm border-b border-gray-50 last:border-0"
|
||||
onClick={() => handleAddressSelect(addr)}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex items-start">
|
||||
<MapPin size={16} className="mt-0.5 mr-2 text-brand-gold flex-shrink-0"/>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">{addr.description}</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{addr.city}, {addr.state}</p>
|
||||
</div>
|
||||
</div>
|
||||
{addr.mapLink && (
|
||||
<span className="flex items-center text-[10px] text-blue-600 bg-blue-50 px-2 py-1 rounded ml-2">
|
||||
<img src="https://www.google.com/images/branding/product/ico/maps15_bnuw3a_32dp.png" alt="Maps" className="w-3 h-3 mr-1"/>
|
||||
Maps
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2">
|
||||
<Input label="Rua" value={formData.address.street} readOnly />
|
||||
</div>
|
||||
<Input
|
||||
label="Número"
|
||||
placeholder="123"
|
||||
value={formData.address.number}
|
||||
onChange={(e) => setFormData({...formData, address: {...formData.address, number: e.target.value}})}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input label="Cidade" value={formData.address.city} readOnly />
|
||||
<Input label="Estado" value={formData.address.state} readOnly />
|
||||
</div>
|
||||
|
||||
{formData.address.mapLink && (
|
||||
<div className="bg-gray-50 p-3 rounded border border-gray-200 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500 flex items-center">
|
||||
<Check size={14} className="mr-1 text-green-500"/>
|
||||
Localização verificada via Google Maps
|
||||
</span>
|
||||
<a href={formData.address.mapLink} target="_blank" rel="noreferrer" className="text-xs text-brand-gold flex items-center hover:underline">
|
||||
Ver no mapa <ExternalLink size={12} className="ml-1"/>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between mt-8">
|
||||
<Button variant="outline" onClick={() => setActiveTab('details')}>Voltar</Button>
|
||||
<Button onClick={() => setActiveTab('briefing')}>Próximo</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'briefing' && (
|
||||
<div className="space-y-6 fade-in">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1 tracking-wide uppercase text-xs">
|
||||
{isClientRequest ? "Conte-nos sobre o seu sonho" : "Briefing Técnico"}
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full border border-gray-300 rounded-sm p-3 focus:outline-none focus:border-brand-gold h-32 text-sm"
|
||||
placeholder={isClientRequest ? "Qual o estilo do casamento? Quais fotos são indispensáveis? Fale um pouco sobre vocês..." : "Instruções técnicas..."}
|
||||
value={formData.briefing}
|
||||
onChange={(e) => setFormData({...formData, briefing: e.target.value})}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<label className="text-sm font-medium text-gray-700 tracking-wide uppercase text-xs">Contatos / Responsáveis</label>
|
||||
<button onClick={addContact} className="text-xs text-brand-gold font-bold hover:underline flex items-center">
|
||||
<Plus size={14} className="mr-1"/> Adicionar
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{formData.contacts.map((contact: any, idx: number) => (
|
||||
<div key={idx} className="flex space-x-2 items-start">
|
||||
<Input
|
||||
label={idx === 0 ? "Nome" : ""}
|
||||
placeholder="Nome"
|
||||
value={contact.name}
|
||||
onChange={(e) => {
|
||||
const newContacts = [...formData.contacts];
|
||||
newContacts[idx].name = e.target.value;
|
||||
setFormData({...formData, contacts: newContacts});
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
label={idx === 0 ? "Papel" : ""}
|
||||
placeholder="Ex: Cerimonialista"
|
||||
value={contact.role}
|
||||
onChange={(e) => {
|
||||
const newContacts = [...formData.contacts];
|
||||
newContacts[idx].role = e.target.value;
|
||||
setFormData({...formData, contacts: newContacts});
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeContact(idx)}
|
||||
className={`mt-1 p-2 text-gray-400 hover:text-red-500 ${idx === 0 ? 'mt-7' : ''}`}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-8">
|
||||
<Button variant="outline" onClick={() => setActiveTab('location')}>Voltar</Button>
|
||||
<Button onClick={() => setActiveTab('files')}>Próximo</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'files' && (
|
||||
<div className="space-y-6 fade-in">
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-10 text-center hover:bg-gray-50 transition-colors relative">
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
<Upload size={40} className="mx-auto text-gray-400 mb-4" />
|
||||
<p className="text-sm text-gray-600 font-medium">
|
||||
{isClientRequest ? "Anexe referências visuais (Moodboard)" : "Anexe contratos e cronogramas"}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">PDF, JPG, PNG (Max 10MB)</p>
|
||||
</div>
|
||||
|
||||
{formData.files.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-gray-700">Arquivos Selecionados:</h4>
|
||||
{formData.files.map((file: any, idx: number) => (
|
||||
<div key={idx} className="flex items-center justify-between p-3 bg-gray-50 rounded border border-gray-100">
|
||||
<div className="flex items-center">
|
||||
<FileText size={18} className="text-brand-gold mr-3"/>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{file.name}</p>
|
||||
<p className="text-xs text-gray-400">{(file.size / 1024).toFixed(1)} KB</p>
|
||||
</div>
|
||||
</div>
|
||||
<Check size={16} className="text-green-500" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between mt-8">
|
||||
<Button variant="outline" onClick={() => setActiveTab('briefing')}>Voltar</Button>
|
||||
<Button onClick={handleSubmit} variant="secondary">{submitLabel}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Input: React.FC<InputProps> = ({ label, error, className = '', ...props }) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1 tracking-wide uppercase text-xs">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
className={`w-full px-4 py-2 border rounded-sm focus:outline-none focus:ring-1 focus:ring-brand-gold focus:border-brand-gold transition-colors
|
||||
${error ? 'border-red-500' : 'border-gray-300'}
|
||||
${className}`}
|
||||
{...props}
|
||||
/>
|
||||
{error && <span className="text-xs text-red-500 mt-1">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label: string;
|
||||
options: { value: string; label: string }[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Select: React.FC<SelectProps> = ({ label, options, error, className = '', ...props }) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1 tracking-wide uppercase text-xs">
|
||||
{label}
|
||||
</label>
|
||||
<select
|
||||
className={`w-full px-4 py-2 border rounded-sm focus:outline-none focus:ring-1 focus:ring-brand-gold focus:border-brand-gold transition-colors appearance-none bg-white
|
||||
${error ? 'border-red-500' : 'border-gray-300'}
|
||||
${className}`}
|
||||
{...props}
|
||||
>
|
||||
<option value="" disabled>Selecione uma opção</option>
|
||||
{options.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{error && <span className="text-xs text-red-500 mt-1">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { UserRole } from '../types';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Menu, X, LogOut } from 'lucide-react';
|
||||
import { Button } from './Button';
|
||||
|
||||
interface NavbarProps {
|
||||
onNavigate: (page: string) => void;
|
||||
currentPage: string;
|
||||
}
|
||||
|
||||
export const Navbar: React.FC<NavbarProps> = ({ onNavigate, currentPage }) => {
|
||||
const { user, logout } = useAuth();
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(window.scrollY > 20);
|
||||
};
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
const getLinks = () => {
|
||||
if (!user) return [];
|
||||
|
||||
switch (user.role) {
|
||||
case UserRole.SUPERADMIN:
|
||||
case UserRole.BUSINESS_OWNER:
|
||||
return [
|
||||
{ name: 'Gestão de Eventos', path: 'dashboard' },
|
||||
{ name: 'Equipe & Fotógrafos', path: 'team' },
|
||||
{ name: 'Financeiro', path: 'finance' },
|
||||
{ name: 'Configurações', path: 'settings' }
|
||||
];
|
||||
case UserRole.EVENT_OWNER:
|
||||
return [
|
||||
{ name: 'Meus Eventos', path: 'dashboard' },
|
||||
{ name: 'Solicitar Evento', path: 'request-event' },
|
||||
{ name: 'Álbuns Entregues', path: 'albums' }
|
||||
];
|
||||
case UserRole.PHOTOGRAPHER:
|
||||
return [
|
||||
{ name: 'Eventos Designados', path: 'dashboard' },
|
||||
{ name: 'Meus Uploads', path: 'uploads' },
|
||||
{ name: 'Agenda', path: 'calendar' }
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleLabel = () => {
|
||||
if (!user) return "";
|
||||
if (user.role === UserRole.BUSINESS_OWNER) return "Empresa";
|
||||
if (user.role === UserRole.EVENT_OWNER) return "Cliente";
|
||||
if (user.role === UserRole.PHOTOGRAPHER) return "Fotógrafo";
|
||||
if (user.role === UserRole.SUPERADMIN) return "Super Admin";
|
||||
return "";
|
||||
};
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={`fixed w-full z-50 transition-all duration-300 border-b ${
|
||||
isScrolled ? 'bg-white/95 backdrop-blur-md shadow-sm border-gray-100 py-2' : 'bg-white border-transparent py-4'
|
||||
}`}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-12">
|
||||
|
||||
{/* Logo */}
|
||||
<div
|
||||
className="flex-shrink-0 flex items-center cursor-pointer"
|
||||
onClick={() => onNavigate('home')}
|
||||
>
|
||||
<img
|
||||
src="https://photum.com.br/wp-content/uploads/2019/09/logo-photum.png"
|
||||
alt="Photum Formaturas"
|
||||
className="h-10 md:h-12 w-auto object-contain transition-all hover:opacity-90"
|
||||
onError={(e) => {
|
||||
e.currentTarget.onerror = null;
|
||||
e.currentTarget.style.display = 'none';
|
||||
e.currentTarget.nextElementSibling?.classList.remove('hidden');
|
||||
}}
|
||||
/>
|
||||
{/* Fallback Text Logo if image fails */}
|
||||
<span className="hidden font-sans font-bold text-xl tracking-tight text-brand-black ml-2">
|
||||
PHOTUM<span className="font-light text-brand-gold">FORMATURAS</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
{user && (
|
||||
<div className="hidden md:flex items-center space-x-6">
|
||||
{getLinks().map((link) => (
|
||||
<button
|
||||
key={link.path}
|
||||
onClick={() => onNavigate(link.path)}
|
||||
className={`text-sm font-medium tracking-wide uppercase hover:text-brand-gold transition-colors pb-1 ${
|
||||
currentPage === link.path ? 'text-brand-gold border-b-2 border-brand-gold' : 'text-gray-600 border-b-2 border-transparent'
|
||||
}`}
|
||||
>
|
||||
{link.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right Side Actions */}
|
||||
<div className="hidden md:flex items-center space-x-4">
|
||||
{user ? (
|
||||
<div className="flex items-center space-x-4 pl-4 border-l border-gray-200">
|
||||
<div className="flex flex-col items-end mr-2">
|
||||
<span className="text-sm font-bold text-brand-black leading-tight">{user.name}</span>
|
||||
<span className="text-[10px] uppercase tracking-wider text-brand-gold leading-tight">{getRoleLabel()}</span>
|
||||
</div>
|
||||
<div className="h-9 w-9 rounded-full bg-gray-100 overflow-hidden border border-gray-200 ring-2 ring-transparent hover:ring-brand-gold transition-all">
|
||||
<img src={user.avatar} alt="Avatar" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<button onClick={logout} className="text-gray-400 hover:text-red-500 transition-colors p-1" title="Sair">
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={() => onNavigate('login')} size="sm">
|
||||
Login
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile Button */}
|
||||
<div className="md:hidden flex items-center">
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
className="text-brand-black hover:text-brand-gold p-2"
|
||||
>
|
||||
{isMobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{isMobileMenuOpen && (
|
||||
<div className="md:hidden absolute top-full left-0 w-full bg-white border-b border-gray-100 shadow-lg fade-in">
|
||||
<div className="px-4 py-4 space-y-3">
|
||||
{user && getLinks().map((link) => (
|
||||
<button
|
||||
key={link.path}
|
||||
onClick={() => {
|
||||
onNavigate(link.path);
|
||||
setIsMobileMenuOpen(false);
|
||||
}}
|
||||
className="block w-full text-left text-base font-medium text-gray-700 hover:text-brand-gold py-2 border-b border-gray-50"
|
||||
>
|
||||
{link.name}
|
||||
</button>
|
||||
))}
|
||||
<div className="pt-4">
|
||||
{user ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<img src={user.avatar} className="w-8 h-8 rounded-full mr-2"/>
|
||||
<div>
|
||||
<span className="font-bold text-sm block">{user.name}</span>
|
||||
<span className="text-xs text-brand-gold">{getRoleLabel()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={logout}>Sair</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button className="w-full" onClick={() => onNavigate('login')}>
|
||||
Acessar Painel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
import { EventData, EventStatus, EventType, Attachment } from '../types';
|
||||
|
||||
// Initial Mock Data
|
||||
const INITIAL_EVENTS: EventData[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Casamento Juliana & Marcos',
|
||||
date: '2024-10-15',
|
||||
time: '16:00',
|
||||
type: EventType.WEDDING,
|
||||
status: EventStatus.CONFIRMED,
|
||||
address: {
|
||||
street: 'Av. das Hortênsias',
|
||||
number: '1200',
|
||||
city: 'Gramado',
|
||||
state: 'RS',
|
||||
zip: '95670-000'
|
||||
},
|
||||
briefing: 'Cerimônia ao pôr do sol. Foco em fotos espontâneas dos noivos e pais.',
|
||||
coverImage: 'https://picsum.photos/id/1059/800/400',
|
||||
contacts: [{ id: 'c1', name: 'Cerimonial Silva', role: 'Cerimonialista', phone: '9999-9999', email: 'c@teste.com'}],
|
||||
checklist: [],
|
||||
attachments: [
|
||||
{ name: 'Ensaio 1', size: '2mb', type: 'image/jpeg', url: 'https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=400&q=80' },
|
||||
{ name: 'Ensaio 2', size: '2mb', type: 'image/jpeg', url: 'https://images.unsplash.com/photo-1511285560982-1351cdeb9821?auto=format&fit=crop&w=400&q=80' }
|
||||
],
|
||||
ownerId: 'client-1',
|
||||
photographerIds: ['photographer-1']
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Conferência Tech Innovators',
|
||||
date: '2024-11-05',
|
||||
time: '08:00',
|
||||
type: EventType.CORPORATE,
|
||||
status: EventStatus.PENDING_APPROVAL,
|
||||
address: {
|
||||
street: 'Rua Olimpíadas',
|
||||
number: '205',
|
||||
city: 'São Paulo',
|
||||
state: 'SP',
|
||||
zip: '04551-000'
|
||||
},
|
||||
briefing: 'Cobrir palestras principais e networking no coffee break.',
|
||||
coverImage: 'https://picsum.photos/id/3/800/400',
|
||||
contacts: [],
|
||||
checklist: [],
|
||||
attachments: [],
|
||||
ownerId: 'client-2', // Other client
|
||||
photographerIds: []
|
||||
}
|
||||
];
|
||||
|
||||
interface DataContextType {
|
||||
events: EventData[];
|
||||
addEvent: (event: EventData) => void;
|
||||
updateEventStatus: (id: string, status: EventStatus) => void;
|
||||
assignPhotographer: (eventId: string, photographerId: string) => void;
|
||||
getEventsByRole: (userId: string, role: string) => EventData[];
|
||||
addAttachment: (eventId: string, attachment: Attachment) => void;
|
||||
}
|
||||
|
||||
const DataContext = createContext<DataContextType | undefined>(undefined);
|
||||
|
||||
export const DataProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [events, setEvents] = useState<EventData[]>(INITIAL_EVENTS);
|
||||
|
||||
const addEvent = (event: EventData) => {
|
||||
setEvents(prev => [event, ...prev]);
|
||||
};
|
||||
|
||||
const updateEventStatus = (id: string, status: EventStatus) => {
|
||||
setEvents(prev => prev.map(e => e.id === id ? { ...e, status } : e));
|
||||
};
|
||||
|
||||
const assignPhotographer = (eventId: string, photographerId: string) => {
|
||||
setEvents(prev => prev.map(e => {
|
||||
if (e.id === eventId) {
|
||||
const current = e.photographerIds || [];
|
||||
if (!current.includes(photographerId)) {
|
||||
return { ...e, photographerIds: [...current, photographerId] };
|
||||
}
|
||||
}
|
||||
return e;
|
||||
}));
|
||||
};
|
||||
|
||||
const getEventsByRole = (userId: string, role: string) => {
|
||||
if (role === 'SUPERADMIN' || role === 'BUSINESS_OWNER') {
|
||||
return events;
|
||||
}
|
||||
if (role === 'EVENT_OWNER') {
|
||||
return events.filter(e => e.ownerId === userId);
|
||||
}
|
||||
if (role === 'PHOTOGRAPHER') {
|
||||
return events.filter(e => e.photographerIds.includes(userId));
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const addAttachment = (eventId: string, attachment: Attachment) => {
|
||||
setEvents(prev => prev.map(e => {
|
||||
if (e.id === eventId) {
|
||||
return { ...e, attachments: [...e.attachments, attachment] };
|
||||
}
|
||||
return e;
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<DataContext.Provider value={{ events, addEvent, updateEventStatus, assignPhotographer, getEventsByRole, addAttachment }}>
|
||||
{children}
|
||||
</DataContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useData = () => {
|
||||
const context = useContext(DataContext);
|
||||
if (!context) throw new Error('useData must be used within a DataProvider');
|
||||
return context;
|
||||
};
|
||||
12
frontend/.env.example
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Configuração da API do Mapbox
|
||||
# Obtenha sua chave gratuita em: https://account.mapbox.com/
|
||||
|
||||
# Para usar:
|
||||
# 1. Copie este arquivo para .env.local
|
||||
# 2. Substitua YOUR_MAPBOX_TOKEN_HERE pela sua chave real
|
||||
# 3. Atualize o arquivo services/mapboxService.ts com sua chave
|
||||
|
||||
VITE_MAPBOX_TOKEN=YOUR_MAPBOX_TOKEN_HERE
|
||||
|
||||
# Nota: A chave já está configurada diretamente no código para demonstração
|
||||
# Em produção, use variáveis de ambiente como acima
|
||||
0
.gitignore → frontend/.gitignore
vendored
266
frontend/App.tsx
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Navbar } from "./components/Navbar";
|
||||
import { Home } from "./pages/Home";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
import { Login } from "./pages/Login";
|
||||
import { Register } from "./pages/Register";
|
||||
import { CalendarPage } from "./pages/Calendar";
|
||||
import { TeamPage } from "./pages/Team";
|
||||
import { FinancePage } from "./pages/Finance";
|
||||
import { SettingsPage } from "./pages/Settings";
|
||||
import { InspirationPage } from "./pages/Inspiration";
|
||||
import { PrivacyPolicy } from "./pages/PrivacyPolicy";
|
||||
import { TermsOfUse } from "./pages/TermsOfUse";
|
||||
import { LGPD } from "./pages/LGPD";
|
||||
import { AuthProvider, useAuth } from "./contexts/AuthContext";
|
||||
import { DataProvider } from "./contexts/DataContext";
|
||||
import { Construction } from "lucide-react";
|
||||
|
||||
const AppContent: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const [currentPage, setCurrentPage] = useState("home");
|
||||
|
||||
useEffect(() => {
|
||||
if (user && currentPage === "login") {
|
||||
setCurrentPage("dashboard");
|
||||
}
|
||||
}, [user, currentPage]);
|
||||
|
||||
const renderPage = () => {
|
||||
if (currentPage === "home")
|
||||
return (
|
||||
<Home onEnter={() => setCurrentPage(user ? "dashboard" : "login")} />
|
||||
);
|
||||
if (currentPage === "login")
|
||||
return user ? <Dashboard /> : <Login onNavigate={setCurrentPage} />;
|
||||
if (currentPage === "register")
|
||||
return user ? <Dashboard /> : <Register onNavigate={setCurrentPage} />;
|
||||
if (currentPage === "privacy")
|
||||
return <PrivacyPolicy onNavigate={setCurrentPage} />;
|
||||
if (currentPage === "terms")
|
||||
return <TermsOfUse onNavigate={setCurrentPage} />;
|
||||
if (currentPage === "lgpd") return <LGPD onNavigate={setCurrentPage} />;
|
||||
|
||||
if (!user) return <Login onNavigate={setCurrentPage} />;
|
||||
|
||||
switch (currentPage) {
|
||||
case "dashboard":
|
||||
case "events":
|
||||
return <Dashboard initialView="list" />;
|
||||
|
||||
case "request-event":
|
||||
return <Dashboard initialView="create" />;
|
||||
|
||||
case "inspiration":
|
||||
return <InspirationPage />;
|
||||
|
||||
case "calendar":
|
||||
return <CalendarPage />;
|
||||
|
||||
case "team":
|
||||
return <TeamPage />;
|
||||
|
||||
case "finance":
|
||||
return <FinancePage />;
|
||||
|
||||
case "settings":
|
||||
return <SettingsPage />;
|
||||
|
||||
default:
|
||||
return <Dashboard initialView="list" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<Navbar onNavigate={setCurrentPage} currentPage={currentPage} />
|
||||
<main>{renderPage()}</main>
|
||||
|
||||
{currentPage === "home" && (
|
||||
<footer className="bg-gradient-to-br from-brand-purple to-brand-purple/90 text-brand-black py-16 md:py-20">
|
||||
<div className="w-full max-w-[1600px] mx-auto px-6 sm:px-8 md:px-12 lg:px-16 xl:px-20">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8 sm:gap-10 md:gap-12 lg:gap-16 xl:gap-20 mb-12 md:mb-16">
|
||||
{/* Logo e Descrição */}
|
||||
<div className="lg:col-span-1">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="Photum Formaturas"
|
||||
className="h-40 sm:h-34 md:h-28 lg:h-42 mb-4 md:mb-6"
|
||||
/>
|
||||
<p className="text-brand-black/80 text-sm sm:text-base md:text-lg leading-relaxed">
|
||||
Eternizando momentos únicos com excelência e profissionalismo
|
||||
desde 2020.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Serviços */}
|
||||
<div>
|
||||
<h4 className="font-bold text-brand-black mb-4 md:mb-6 uppercase tracking-wider text-sm sm:text-base md:text-lg">
|
||||
Serviços
|
||||
</h4>
|
||||
<ul className="space-y-2 md:space-y-3 text-brand-black/70 text-sm sm:text-base md:text-lg">
|
||||
<li>Fotografia de Formatura</li>
|
||||
<li>Baile de Gala</li>
|
||||
<li>Cerimônia de Colação</li>
|
||||
<li>Ensaios de Turma</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Links Úteis */}
|
||||
<div>
|
||||
<h4 className="font-bold text-brand-black mb-4 md:mb-6 uppercase tracking-wider text-sm sm:text-base md:text-lg">
|
||||
Links Úteis
|
||||
</h4>
|
||||
<ul className="space-y-2 md:space-y-3 text-brand-black/70 text-sm sm:text-base md:text-lg">
|
||||
<li>
|
||||
<a
|
||||
href="#"
|
||||
onClick={() => setCurrentPage("login")}
|
||||
className="hover:text-brand-black transition-colors"
|
||||
>
|
||||
Área do Cliente
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#"
|
||||
onClick={() => setCurrentPage("register")}
|
||||
className="hover:text-brand-black transition-colors"
|
||||
>
|
||||
Cadastre sua Formatura
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Contato */}
|
||||
<div>
|
||||
<h4 className="font-bold text-brand-black mb-4 md:mb-6 uppercase tracking-wider text-sm sm:text-base md:text-lg">
|
||||
Contato
|
||||
</h4>
|
||||
<ul className="space-y-3 md:space-y-4 text-brand-black/70 text-sm sm:text-base md:text-lg">
|
||||
<li className="flex items-center gap-2 md:gap-3">
|
||||
<svg
|
||||
className="w-5 h-5 md:w-6 md:h-6 flex-shrink-0"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z" />
|
||||
</svg>
|
||||
<a
|
||||
href="mailto:contato@photum.com.br"
|
||||
className="hover:text-brand-black transition-colors break-all"
|
||||
>
|
||||
contato@photum.com.br
|
||||
</a>
|
||||
</li>
|
||||
<li className="flex items-start gap-2 md:gap-3">
|
||||
<svg
|
||||
className="w-5 h-5 md:w-6 md:h-6 flex-shrink-0 mt-0.5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M20 15.5c-1.25 0-2.45-.2-3.57-.57-.35-.11-.74-.03-1.02.24l-2.2 2.2c-2.83-1.44-5.15-3.75-6.59-6.59l2.2-2.21c.28-.26.36-.65.25-1C8.7 6.45 8.5 5.25 8.5 4c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1 0 9.39 7.61 17 17 17 .55 0 1-.45 1-1v-3.5c0-.55-.45-1-1-1zM19 12h2c0-4.97-4.03-9-9-9v2c3.87 0 7 3.13 7 7zm-4 0h2c0-2.76-2.24-5-5-5v2c1.66 0 3 1.34 3 3z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p>(19) 3405 5024</p>
|
||||
<p>(19) 3621 4621</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-center gap-2 md:gap-3">
|
||||
<svg
|
||||
className="w-5 h-5 md:w-6 md:h-6 flex-shrink-0"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z" />
|
||||
</svg>
|
||||
<span>Americana, SP</span>
|
||||
</li>
|
||||
<li className="flex gap-4 md:gap-5 mt-4 md:mt-6">
|
||||
<a
|
||||
href="#"
|
||||
className="hover:text-brand-black transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-7 h-7 md:w-8 md:h-8"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="hover:text-brand-black transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-7 h-7 md:w-8 md:h-8"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M22.675 0h-21.35c-.732 0-1.325.593-1.325 1.325v21.351c0 .731.593 1.324 1.325 1.324h11.495v-9.294h-3.128v-3.622h3.128v-2.671c0-3.1 1.893-4.788 4.659-4.788 1.325 0 2.463.099 2.795.143v3.24l-1.918.001c-1.504 0-1.795.715-1.795 1.763v2.313h3.587l-.467 3.622h-3.12v9.293h6.116c.73 0 1.323-.593 1.323-1.325v-21.35c0-.732-.593-1.325-1.325-1.325z" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="hover:text-brand-black transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-7 h-7 md:w-8 md:h-8"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Bar */}
|
||||
<div className="border-t border-black/20 pt-6 md:pt-8 flex flex-col md:flex-row justify-between items-center text-xs sm:text-sm md:text-base text-brand-black/60 gap-4">
|
||||
<p>© 2025 PhotumFormaturas. Todos os direitos reservados.</p>
|
||||
<div className="flex flex-wrap justify-center gap-4 sm:gap-6 md:gap-8">
|
||||
<a
|
||||
href="#"
|
||||
onClick={() => setCurrentPage("privacy")}
|
||||
className="hover:text-brand-black transition-colors"
|
||||
>
|
||||
Política de Privacidade
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
onClick={() => setCurrentPage("terms")}
|
||||
className="hover:text-brand-black transition-colors"
|
||||
>
|
||||
Termos de Uso
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
onClick={() => setCurrentPage("lgpd")}
|
||||
className="hover:text-brand-black transition-colors"
|
||||
>
|
||||
LGPD
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<DataProvider>
|
||||
<AppContent />
|
||||
</DataProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
79
frontend/MAPBOX_SETUP.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# 🗺️ Configuração do Mapbox
|
||||
|
||||
## Token do Mapbox Inválido
|
||||
|
||||
O sistema está configurado para usar a API do Mapbox, mas o token atual é inválido ou expirado.
|
||||
|
||||
## Como Obter um Token Válido (GRATUITO)
|
||||
|
||||
### 1. Crie uma conta no Mapbox
|
||||
|
||||
- Acesse: **https://account.mapbox.com/**
|
||||
- Clique em **"Sign up"** (ou faça login se já tiver conta)
|
||||
- É **100% gratuito** para até 50.000 requisições/mês
|
||||
|
||||
### 2. Acesse a página de Tokens
|
||||
|
||||
- Após fazer login, vá para: **https://account.mapbox.com/access-tokens/**
|
||||
- Ou clique no menu em **"Access tokens"**
|
||||
|
||||
### 3. Crie um novo token
|
||||
|
||||
- Clique em **"Create a token"**
|
||||
- Dê um nome (ex: "Photum Forms")
|
||||
- Selecione os escopos necessários (deixe os padrões marcados)
|
||||
- Clique em **"Create token"**
|
||||
|
||||
### 4. Copie o token
|
||||
|
||||
- O token será algo como: `pk.eyJ1IjoiZXhhbXBsZSIsImEiOiJja...`
|
||||
- **COPIE O TOKEN COMPLETO**
|
||||
|
||||
### 5. Configure no Projeto
|
||||
|
||||
Abra o arquivo **`services/mapboxService.ts`** e substitua o token na linha 26:
|
||||
|
||||
```typescript
|
||||
const MAPBOX_TOKEN = "SEU_TOKEN_AQUI"; // Cole o token do Mapbox aqui
|
||||
```
|
||||
|
||||
### 6. Salve e recarregue
|
||||
|
||||
Após salvar o arquivo, o Vite recarregará automaticamente e o mapa funcionará!
|
||||
|
||||
---
|
||||
|
||||
## Verificação
|
||||
|
||||
Se tudo estiver correto, você verá:
|
||||
|
||||
- ✅ "Inicializando mapa Mapbox..." no console
|
||||
- ✅ "Mapa criado com sucesso" no console
|
||||
- ✅ Mapa interativo carregado na tela de criação de eventos
|
||||
|
||||
Se houver erro:
|
||||
|
||||
- ❌ Verifique se copiou o token completo (incluindo `pk.`)
|
||||
- ❌ Verifique se não há espaços extras antes/depois do token
|
||||
- ❌ Certifique-se de que o token não expirou
|
||||
|
||||
---
|
||||
|
||||
## Recursos do Mapbox no Sistema
|
||||
|
||||
- 🔍 Busca de endereços com autocomplete
|
||||
- 📍 Mapa interativo com pin arrastável
|
||||
- 🌍 Geocoding e Reverse Geocoding
|
||||
- 🗺️ Integração com Google Maps para compartilhamento
|
||||
|
||||
## Limites Gratuitos
|
||||
|
||||
O plano gratuito do Mapbox inclui:
|
||||
|
||||
- 50.000 requisições de geocoding/mês
|
||||
- 50.000 carregamentos de mapa/mês
|
||||
- Mais que suficiente para este projeto!
|
||||
|
||||
---
|
||||
|
||||
**Precisa de ajuda?** Acesse a documentação: https://docs.mapbox.com/api/
|
||||
397
frontend/README.md
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
# 📸 Photum Manager
|
||||
|
||||
Sistema de gestão completa para eventos fotográficos premium. Plataforma moderna para gerenciamento de eventos, fotógrafos, clientes e entregas, com foco em excelência e atendimento humanizado.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Visão Geral
|
||||
|
||||
O **Photum Manager** é uma aplicação web desenvolvida em React + TypeScript que centraliza todo o fluxo de trabalho de uma empresa de fotografia de eventos, desde a solicitação inicial do cliente até a entrega final do álbum.
|
||||
|
||||
### Principais Funcionalidades
|
||||
|
||||
- ✅ **Gestão Multi-perfil**: Suporte para 4 tipos de usuários (Superadmin, Empresa, Fotógrafo, Cliente)
|
||||
- ✅ **Controle de Eventos**: CRUD completo de eventos com status workflow
|
||||
- ✅ **Dashboard Personalizado**: Interface adaptada por perfil de usuário
|
||||
- ✅ **Sistema de Aprovações**: Workflow de aprovação de eventos para clientes
|
||||
- ✅ **Gestão de Equipe**: Atribuição de fotógrafos aos eventos
|
||||
- ✅ **Upload de Fotos**: Sistema de anexos e galeria (em desenvolvimento)
|
||||
- ✅ **Integração com IA**: Google GenAI para funcionalidades avançadas
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Como Executar o Projeto
|
||||
|
||||
### Pré-requisitos
|
||||
|
||||
- **Node.js** (versão 16 ou superior)
|
||||
- **npm** ou **yarn**
|
||||
|
||||
### Instalação
|
||||
|
||||
1. **Clone o repositório**:
|
||||
```bash
|
||||
git clone https://github.com/rede5/photum-frontend.git
|
||||
cd photum-frontend
|
||||
```
|
||||
|
||||
2. **Instale as dependências**:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Configure a API do Gemini** (opcional):
|
||||
- Crie um arquivo `.env.local` na raiz do projeto
|
||||
- Adicione sua chave de API:
|
||||
```
|
||||
GEMINI_API_KEY=sua_chave_aqui
|
||||
```
|
||||
|
||||
4. **Execute o projeto em modo desenvolvimento**:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
5. **Acesse no navegador**:
|
||||
```
|
||||
http://localhost:5173
|
||||
```
|
||||
|
||||
### Build para Produção
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Rotas e Navegação
|
||||
|
||||
O sistema utiliza um **roteamento customizado baseado em estados** (sem React Router). As rotas disponíveis são:
|
||||
|
||||
| Rota | Descrição | Acesso |
|
||||
|------|-----------|--------|
|
||||
| `/` (`home`) | Landing page institucional | Público |
|
||||
| `/login` | Tela de autenticação | Público |
|
||||
| `/dashboard` | Dashboard principal (Lista de eventos) | Autenticado |
|
||||
| `/events` | Mesma view de dashboard | Autenticado |
|
||||
| `/request-event` | Formulário de criação de evento | Autenticado |
|
||||
| `/uploads` | Gerenciamento de uploads de fotos | Autenticado |
|
||||
| `/team` | Gestão de equipe e fotógrafos | 🚧 Em desenvolvimento |
|
||||
| `/finance` | Módulo financeiro | 🚧 Em desenvolvimento |
|
||||
| `/calendar` | Agenda de eventos | 🚧 Em desenvolvimento |
|
||||
| `/settings` | Configurações | 🚧 Em desenvolvimento |
|
||||
| `/albums` | Álbuns de fotos | 🚧 Em desenvolvimento |
|
||||
|
||||
### Navegação no Sistema
|
||||
|
||||
A navegação acontece através do componente `<Navbar>` que chama a função `onNavigate(pageName)` passando o nome da rota desejada. Rotas protegidas redirecionam automaticamente para `/login` se o usuário não estiver autenticado.
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Tela de Login e Usuários de Demonstração
|
||||
|
||||
### Como Acessar
|
||||
|
||||
1. Na página inicial, clique em **"Área do Cliente"**
|
||||
2. Você será redirecionado para a **tela de login** (`/login`)
|
||||
|
||||
### Interface de Login
|
||||
|
||||
A tela de login apresenta:
|
||||
- **Layout dividido**:
|
||||
- Lado esquerdo com imagem institucional
|
||||
- Lado direito com formulário de login
|
||||
- **Campos**:
|
||||
- E-mail corporativo ou pessoal
|
||||
- Senha (desabilitada no modo demo)
|
||||
- **Botão**: "Entrar no Sistema"
|
||||
|
||||
### Usuários de Demonstração
|
||||
|
||||
Não é necessário senha. Basta clicar em um dos cartões de usuário demo ou digitar o e-mail:
|
||||
|
||||
| Perfil | Nome | E-mail | Descrição |
|
||||
|--------|------|--------|-----------|
|
||||
| **Superadmin** | Dev Admin | `admin@photum.com` | Acesso total ao sistema |
|
||||
| **Empresa** | Carlos CEO | `empresa@photum.com` | Dono da empresa de fotografia |
|
||||
| **Fotógrafo** | Ana Lente | `foto@photum.com` | Profissional fotógrafo |
|
||||
| **Cliente** | Juliana Noiva | `cliente@photum.com` | Cliente final (dono do evento) |
|
||||
|
||||
### Fluxo de Login
|
||||
|
||||
```
|
||||
1. Usuário clica em "Área do Cliente" na Home
|
||||
↓
|
||||
2. Sistema redireciona para /login
|
||||
↓
|
||||
3. Usuário seleciona um perfil demo OU digita e-mail manualmente
|
||||
↓
|
||||
4. Sistema valida o e-mail com mock de usuários
|
||||
↓
|
||||
5. Se válido: redireciona para /dashboard
|
||||
Se inválido: exibe mensagem de erro
|
||||
```
|
||||
|
||||
### Exemplo de Código (Login)
|
||||
|
||||
```tsx
|
||||
// Login simplificado (sem senha)
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const success = await login(email); // Busca no mock de usuários
|
||||
if (!success) {
|
||||
setError('Usuário não encontrado');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 👥 Perfis de Usuário e Permissões
|
||||
|
||||
### 1. Superadmin (`SUPERADMIN`)
|
||||
- **Acesso**: Total ao sistema
|
||||
- **Funcionalidades**:
|
||||
- Visualiza todos os eventos
|
||||
- Gerencia todos os usuários
|
||||
- Acesso a configurações avançadas
|
||||
|
||||
### 2. Dono da Empresa (`BUSINESS_OWNER`)
|
||||
- **Acesso**: Administrativo
|
||||
- **Funcionalidades**:
|
||||
- Cria e edita eventos
|
||||
- Atribui fotógrafos
|
||||
- Aprova solicitações de clientes
|
||||
- Gerencia equipe
|
||||
|
||||
### 3. Fotógrafo (`PHOTOGRAPHER`)
|
||||
- **Acesso**: Operacional
|
||||
- **Funcionalidades**:
|
||||
- Visualiza eventos atribuídos
|
||||
- Atualiza status de eventos
|
||||
- Faz upload de fotos
|
||||
- Acessa detalhes de contatos
|
||||
|
||||
### 4. Cliente (`EVENT_OWNER`)
|
||||
- **Acesso**: Restrito aos próprios eventos
|
||||
- **Funcionalidades**:
|
||||
- Solicita novos eventos
|
||||
- Acompanha status do seu evento
|
||||
- Visualiza fotos do evento
|
||||
- Eventos criados entram em status "Aguardando Aprovação"
|
||||
|
||||
---
|
||||
|
||||
## 📊 Status de Eventos
|
||||
|
||||
Os eventos seguem um workflow com os seguintes status:
|
||||
|
||||
| Status | Descrição | Cor |
|
||||
|--------|-----------|-----|
|
||||
| `Aguardando Aprovação` | Cliente criou, aguarda aprovação da empresa | Cinza |
|
||||
| `Em Planejamento` | Aprovado, em fase de planejamento | Azul |
|
||||
| `Confirmado` | Evento confirmado e agendado | Verde |
|
||||
| `Em Execução` | Evento acontecendo | Roxo |
|
||||
| `Entregue` | Fotos entregues ao cliente | Verde escuro |
|
||||
| `Arquivado` | Evento finalizado e arquivado | Cinza escuro |
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Tecnologias Utilizadas
|
||||
|
||||
### Core
|
||||
- **React 19.2.0** - Biblioteca JavaScript para UI
|
||||
- **TypeScript 5.8.2** - Superset tipado do JavaScript
|
||||
- **Vite 6.2.0** - Build tool e dev server ultra-rápido
|
||||
|
||||
### UI/UX
|
||||
- **Lucide React** - Ícones modernos e customizáveis
|
||||
- **TailwindCSS** - Framework CSS utility-first (configurado via classes)
|
||||
|
||||
### IA e APIs
|
||||
- **@google/genai 1.30.0** - Integração com Google Gemini AI
|
||||
|
||||
### Build Tools
|
||||
- **@vitejs/plugin-react 5.0.0** - Plugin oficial do Vite para React
|
||||
- **@types/node 22.14.0** - Tipos TypeScript para Node.js
|
||||
|
||||
---
|
||||
|
||||
## 📁 Estrutura do Projeto
|
||||
|
||||
```
|
||||
photum-forms/
|
||||
├── components/ # Componentes reutilizáveis
|
||||
│ ├── Button.tsx # Botão customizado
|
||||
│ ├── EventCard.tsx # Card de evento
|
||||
│ ├── EventForm.tsx # Formulário de evento
|
||||
│ ├── Input.tsx # Input customizado
|
||||
│ └── Navbar.tsx # Barra de navegação
|
||||
├── contexts/ # Context API
|
||||
│ ├── AuthContext.tsx # Autenticação e usuários
|
||||
│ └── DataContext.tsx # Gerenciamento de eventos
|
||||
├── pages/ # Páginas principais
|
||||
│ ├── Dashboard.tsx # Dashboard principal
|
||||
│ ├── Home.tsx # Landing page
|
||||
│ └── Login.tsx # Tela de login
|
||||
├── services/ # Serviços e APIs
|
||||
│ ├── genaiService.ts # Integração Google GenAI
|
||||
│ └── mockGeoService.ts # Mock de geolocalização
|
||||
├── App.tsx # Componente raiz + roteamento
|
||||
├── constants.ts # Constantes globais
|
||||
├── types.ts # Tipos TypeScript
|
||||
├── index.tsx # Entry point
|
||||
├── package.json # Dependências
|
||||
├── tsconfig.json # Config TypeScript
|
||||
└── vite.config.ts # Config Vite
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Temas e Design System
|
||||
|
||||
### Cores da Marca
|
||||
|
||||
```css
|
||||
/* Cores principais */
|
||||
--brand-black: #000000
|
||||
--brand-gold: #c5a059
|
||||
--brand-white: #ffffff
|
||||
|
||||
/* Status Colors (definidas em constants.ts) */
|
||||
Aguardando: #6b7280 (gray)
|
||||
Planejamento: #3b82f6 (blue)
|
||||
Confirmado: #10b981 (green)
|
||||
Em Execução: #8b5cf6 (purple)
|
||||
Entregue: #059669 (emerald)
|
||||
Arquivado: #4b5563 (gray-dark)
|
||||
```
|
||||
|
||||
### Tipografia
|
||||
|
||||
- **Títulos**: Font Serif (elegante e sofisticada)
|
||||
- **Corpo**: Font Sans (moderna e legível)
|
||||
- **Tamanhos**: Sistema responsivo com classes Tailwind
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Workflows do Sistema
|
||||
|
||||
### Workflow de Criação de Evento (Cliente)
|
||||
|
||||
```
|
||||
1. Cliente faz login
|
||||
↓
|
||||
2. Acessa "Solicitar Evento"
|
||||
↓
|
||||
3. Preenche formulário (nome, tipo, data, endereço, contatos)
|
||||
↓
|
||||
4. Evento criado com status "Aguardando Aprovação"
|
||||
↓
|
||||
5. Empresa visualiza solicitação
|
||||
↓
|
||||
6. Empresa aprova → Status muda para "Confirmado"
|
||||
↓
|
||||
7. Empresa atribui fotógrafo
|
||||
↓
|
||||
8. Fotógrafo acessa evento e realiza trabalho
|
||||
↓
|
||||
9. Status atualizado conforme workflow
|
||||
↓
|
||||
10. Cliente visualiza fotos e álbum final
|
||||
```
|
||||
|
||||
### Workflow de Gestão (Empresa)
|
||||
|
||||
```
|
||||
1. Empresa cria evento diretamente (status: "Em Planejamento")
|
||||
↓
|
||||
2. Adiciona informações detalhadas
|
||||
↓
|
||||
3. Atribui fotógrafos à equipe
|
||||
↓
|
||||
4. Confirma evento (status: "Confirmado")
|
||||
↓
|
||||
5. Acompanha execução
|
||||
↓
|
||||
6. Marca como entregue após conclusão
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Dados de Teste (Mock)
|
||||
|
||||
O sistema utiliza dados mockados para demonstração:
|
||||
|
||||
### Usuários Mock
|
||||
- 4 usuários pré-cadastrados (ver seção de Login)
|
||||
|
||||
### Eventos Mock
|
||||
- Eventos de exemplo criados no `DataContext.tsx`
|
||||
- Incluem diferentes tipos: Casamentos, Corporativos, Aniversários
|
||||
- Variados status para demonstrar o workflow
|
||||
|
||||
---
|
||||
|
||||
## 🚧 Funcionalidades em Desenvolvimento
|
||||
|
||||
As seguintes rotas exibem tela de "Em construção":
|
||||
- `/team` - Gestão de Equipe e Fotógrafos
|
||||
- `/finance` - Módulo Financeiro
|
||||
- `/calendar` - Agenda Completa
|
||||
- `/settings` - Configurações do Sistema
|
||||
- `/albums` - Galeria de Álbuns
|
||||
|
||||
---
|
||||
|
||||
## 📝 Scripts Disponíveis
|
||||
|
||||
| Comando | Descrição |
|
||||
|---------|-----------|
|
||||
| `npm run dev` | Inicia servidor de desenvolvimento (Vite) |
|
||||
| `npm run build` | Gera build de produção otimizado |
|
||||
| `npm run preview` | Preview do build de produção |
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contribuindo
|
||||
|
||||
Este é um projeto em desenvolvimento ativo. Contribuições são bem-vindas!
|
||||
|
||||
### Branch Strategy
|
||||
- **main**: Produção
|
||||
- **dev**: Desenvolvimento ativo (branch atual)
|
||||
|
||||
---
|
||||
|
||||
## 📄 Licença
|
||||
|
||||
Projeto privado - Todos os direitos reservados © 2024 PhotumFormaturas
|
||||
|
||||
---
|
||||
|
||||
## 📞 Suporte
|
||||
|
||||
Para dúvidas ou suporte:
|
||||
- **Repositório**: [github.com/rede5/photum-frontend](https://github.com/rede5/photum-frontend)
|
||||
- **Issues**: Abra uma issue no GitHub
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Roadmap Futuro
|
||||
|
||||
- [ ] Implementar módulo de Agenda/Calendário
|
||||
- [ ] Sistema completo de Upload de Fotos
|
||||
- [ ] Módulo Financeiro (Pagamentos e Orçamentos)
|
||||
- [ ] Gestão de Equipe completa
|
||||
- [ ] Sistema de Notificações em tempo real
|
||||
- [ ] App Mobile (React Native)
|
||||
- [ ] Integração com APIs de pagamento
|
||||
- [ ] Assinatura digital de contratos
|
||||
- [ ] Exportação de relatórios PDF
|
||||
|
||||
---
|
||||
|
||||
**Desenvolvido com ❤️ para eternizar momentos únicos** ✨📸
|
||||
|
|
@ -2,23 +2,23 @@ import React from 'react';
|
|||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
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}
|
||||
825
frontend/components/EventForm.tsx
Normal file
|
|
@ -0,0 +1,825 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { EventType, EventStatus, Address } from "../types";
|
||||
import { Input, Select } from "./Input";
|
||||
import { Button } from "./Button";
|
||||
import {
|
||||
MapPin,
|
||||
Upload,
|
||||
Plus,
|
||||
X,
|
||||
Check,
|
||||
FileText,
|
||||
ExternalLink,
|
||||
Search,
|
||||
CheckCircle,
|
||||
Building2,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
searchMapboxLocation,
|
||||
MapboxResult,
|
||||
reverseGeocode,
|
||||
} from "../services/mapboxService";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { useData } from "../contexts/DataContext";
|
||||
import { UserRole } from "../types";
|
||||
import { InstitutionForm } from "./InstitutionForm";
|
||||
import { MapboxMap } from "./MapboxMap";
|
||||
|
||||
interface EventFormProps {
|
||||
onCancel: () => void;
|
||||
onSubmit: (data: any) => void;
|
||||
initialData?: any;
|
||||
}
|
||||
|
||||
export const EventForm: React.FC<EventFormProps> = ({
|
||||
onCancel,
|
||||
onSubmit,
|
||||
initialData,
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const { institutions, getInstitutionsByUserId, addInstitution } = useData();
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"details" | "location" | "briefing" | "files"
|
||||
>("details");
|
||||
const [addressQuery, setAddressQuery] = useState("");
|
||||
const [addressResults, setAddressResults] = useState<MapboxResult[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isGeocoding, setIsGeocoding] = useState(false);
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
const [showInstitutionForm, setShowInstitutionForm] = useState(false);
|
||||
|
||||
// Get institutions based on user role
|
||||
// Business owners and admins see all institutions, clients see only their own
|
||||
const userInstitutions = user
|
||||
? user.role === UserRole.BUSINESS_OWNER || user.role === UserRole.SUPERADMIN
|
||||
? institutions
|
||||
: getInstitutionsByUserId(user.id)
|
||||
: [];
|
||||
|
||||
// Default State or Initial Data
|
||||
const [formData, setFormData] = useState(
|
||||
initialData || {
|
||||
name: "",
|
||||
date: "",
|
||||
time: "",
|
||||
type: "",
|
||||
status: EventStatus.PLANNING,
|
||||
address: {
|
||||
street: "",
|
||||
number: "",
|
||||
city: "",
|
||||
state: "",
|
||||
zip: "",
|
||||
lat: -23.5505,
|
||||
lng: -46.6333,
|
||||
mapLink: "",
|
||||
} as Address,
|
||||
briefing: "",
|
||||
contacts: [{ name: "", role: "", phone: "" }],
|
||||
files: [] as File[],
|
||||
coverImage:
|
||||
"https://images.unsplash.com/photo-1511795409834-ef04bbd61622?ixlib=rb-1.2.1&auto=format&fit=crop&w=800&q=80", // Default
|
||||
institutionId: "",
|
||||
}
|
||||
);
|
||||
|
||||
const isClientRequest = user?.role === UserRole.EVENT_OWNER;
|
||||
const formTitle = initialData
|
||||
? "Editar Evento"
|
||||
: isClientRequest
|
||||
? "Solicitar Orçamento/Evento"
|
||||
: "Cadastrar Novo Evento";
|
||||
const submitLabel = initialData
|
||||
? "Salvar Alterações"
|
||||
: isClientRequest
|
||||
? "Enviar Solicitação"
|
||||
: "Criar Evento";
|
||||
|
||||
// Address Autocomplete Logic using Mapbox
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(async () => {
|
||||
if (addressQuery.length > 3) {
|
||||
setIsSearching(true);
|
||||
const results = await searchMapboxLocation(addressQuery);
|
||||
setAddressResults(results);
|
||||
setIsSearching(false);
|
||||
} else {
|
||||
setAddressResults([]);
|
||||
}
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [addressQuery]);
|
||||
|
||||
const handleAddressSelect = (addr: MapboxResult) => {
|
||||
setFormData((prev: any) => ({
|
||||
...prev,
|
||||
address: {
|
||||
street: addr.street,
|
||||
number: addr.number,
|
||||
city: addr.city,
|
||||
state: addr.state,
|
||||
zip: addr.zip,
|
||||
lat: addr.lat,
|
||||
lng: addr.lng,
|
||||
mapLink: addr.mapLink,
|
||||
},
|
||||
}));
|
||||
setAddressQuery("");
|
||||
setAddressResults([]);
|
||||
};
|
||||
|
||||
const handleMapLocationChange = async (lat: number, lng: number) => {
|
||||
// Buscar endereço baseado nas coordenadas
|
||||
const addressData = await reverseGeocode(lat, lng);
|
||||
|
||||
if (addressData) {
|
||||
setFormData((prev: any) => ({
|
||||
...prev,
|
||||
address: {
|
||||
street: addressData.street,
|
||||
number: addressData.number,
|
||||
city: addressData.city,
|
||||
state: addressData.state,
|
||||
zip: addressData.zip,
|
||||
lat: addressData.lat,
|
||||
lng: addressData.lng,
|
||||
mapLink: addressData.mapLink,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
// Se não conseguir o endereço, atualiza apenas as coordenadas
|
||||
setFormData((prev: any) => ({
|
||||
...prev,
|
||||
address: {
|
||||
...prev.address,
|
||||
lat,
|
||||
lng,
|
||||
mapLink: `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`,
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Geocoding quando o usuário digita o endereço manualmente
|
||||
const handleManualAddressChange = async () => {
|
||||
const { street, number, city, state } = formData.address;
|
||||
|
||||
// Montar query de busca
|
||||
const query = `${street} ${number}, ${city}, ${state}`.trim();
|
||||
|
||||
if (query.length < 5) return; // Endereço muito curto
|
||||
|
||||
setIsGeocoding(true);
|
||||
|
||||
try {
|
||||
const results = await searchMapboxLocation(query);
|
||||
|
||||
if (results.length > 0) {
|
||||
const firstResult = results[0];
|
||||
|
||||
setFormData((prev: any) => ({
|
||||
...prev,
|
||||
address: {
|
||||
...prev.address,
|
||||
lat: firstResult.lat,
|
||||
lng: firstResult.lng,
|
||||
mapLink: firstResult.mapLink,
|
||||
},
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao geocodificar endereço manual:", error);
|
||||
} finally {
|
||||
setIsGeocoding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addContact = () => {
|
||||
setFormData((prev: any) => ({
|
||||
...prev,
|
||||
contacts: [...prev.contacts, { name: "", role: "", phone: "" }],
|
||||
}));
|
||||
};
|
||||
|
||||
const removeContact = (index: number) => {
|
||||
const newContacts = [...formData.contacts];
|
||||
newContacts.splice(index, 1);
|
||||
setFormData((prev: any) => ({ ...prev, contacts: newContacts }));
|
||||
};
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
setFormData((prev: any) => ({
|
||||
...prev,
|
||||
files: [...prev.files, ...Array.from(e.target.files || [])],
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
// Validate institution selection
|
||||
if (!formData.institutionId) {
|
||||
alert("Por favor, selecione uma instituição antes de continuar.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Show toast
|
||||
setShowToast(true);
|
||||
// Call original submit after small delay for visual effect or immediately
|
||||
setTimeout(() => {
|
||||
onSubmit(formData);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const handleInstitutionSubmit = (institutionData: any) => {
|
||||
const newInstitution = {
|
||||
...institutionData,
|
||||
id: `inst-${Date.now()}`,
|
||||
ownerId: user?.id || "",
|
||||
};
|
||||
addInstitution(newInstitution);
|
||||
setFormData((prev) => ({ ...prev, institutionId: newInstitution.id }));
|
||||
setShowInstitutionForm(false);
|
||||
};
|
||||
|
||||
// Show institution form modal
|
||||
if (showInstitutionForm) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<InstitutionForm
|
||||
onCancel={() => setShowInstitutionForm(false)}
|
||||
onSubmit={handleInstitutionSubmit}
|
||||
userId={user?.id || ""}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-xl overflow-hidden max-w-4xl mx-auto border border-gray-100 slide-up relative">
|
||||
{/* Success Toast */}
|
||||
{showToast && (
|
||||
<div className="absolute top-4 right-4 z-50 bg-brand-black text-white px-6 py-4 rounded shadow-2xl flex items-center space-x-3 fade-in">
|
||||
<CheckCircle className="text-brand-gold h-6 w-6" />
|
||||
<div>
|
||||
<h4 className="font-bold text-sm">Sucesso!</h4>
|
||||
<p className="text-xs text-gray-300">
|
||||
As informações foram salvas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form Header */}
|
||||
<div className="bg-gray-50 border-b px-8 py-6 flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-serif text-brand-black">{formTitle}</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{isClientRequest
|
||||
? "Preencha os detalhes do seu sonho. Nossa equipe analisará em breve."
|
||||
: "Preencha as informações técnicas do evento."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
{["details", "location", "briefing", "files"].map((tab, idx) => (
|
||||
<div
|
||||
key={tab}
|
||||
className={`flex flex-col items-center ${
|
||||
activeTab === tab ? "opacity-100" : "opacity-40"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold mb-1 ${
|
||||
activeTab === tab
|
||||
? "bg-brand-black text-white"
|
||||
: "bg-gray-200 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{idx + 1}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 min-h-[500px]">
|
||||
{/* Sidebar Navigation for Form */}
|
||||
<div className="col-span-1 border-r border-gray-100 bg-gray-50/50 p-4 space-y-2">
|
||||
{[
|
||||
{ id: "details", label: "Detalhes & Data" },
|
||||
{ id: "location", label: "Localização" },
|
||||
{
|
||||
id: "briefing",
|
||||
label: isClientRequest ? "Seus Desejos" : "Briefing & Equipe",
|
||||
},
|
||||
{ id: "files", label: "Inspirações" },
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActiveTab(item.id as any)}
|
||||
className={`w-full text-left px-4 py-3 rounded-sm text-sm font-medium transition-colors ${
|
||||
activeTab === item.id
|
||||
? "bg-white shadow-sm text-brand-gold border-l-4 border-brand-gold"
|
||||
: "text-gray-500 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Form Content */}
|
||||
<div className="col-span-3 p-8">
|
||||
{activeTab === "details" && (
|
||||
<div className="space-y-6 fade-in">
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<Input
|
||||
label="Nome do Evento (Opcional)"
|
||||
placeholder="Ex: Casamento Silva & Souza"
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, name: e.target.value })
|
||||
}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Data Pretendida"
|
||||
type="date"
|
||||
value={formData.date}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, date: e.target.value })
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label="Horário Aproximado"
|
||||
type="time"
|
||||
value={formData.time}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, time: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
label="Tipo de Evento"
|
||||
options={Object.values(EventType).map((t) => ({
|
||||
value: t,
|
||||
label: t,
|
||||
}))}
|
||||
value={formData.type}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, type: e.target.value })
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Institution Selection - OBRIGATÓRIO */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1 tracking-wide uppercase text-xs">
|
||||
Universidade*{" "}
|
||||
<span className="text-brand-gold">(Obrigatório)</span>
|
||||
</label>
|
||||
|
||||
{userInstitutions.length === 0 ? (
|
||||
<div className="border-2 border-dashed border-amber-300 bg-amber-50 rounded-sm p-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<AlertCircle
|
||||
className="text-amber-600 flex-shrink-0 mt-0.5"
|
||||
size={20}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-amber-900 mb-2">
|
||||
Nenhuma universidade cadastrada
|
||||
</p>
|
||||
<p className="text-xs text-amber-700 mb-3">
|
||||
Você precisa cadastrar uma universidade antes de
|
||||
criar um evento. Trabalhamos exclusivamente com
|
||||
eventos fotográficos em universidades.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowInstitutionForm(true)}
|
||||
className="text-xs font-bold text-amber-900 hover:text-amber-700 underline flex items-center"
|
||||
>
|
||||
<Plus size={14} className="mr-1" />
|
||||
Cadastrar minha primeira universidade
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-sm focus:outline-none focus:ring-1 focus:ring-brand-gold focus:border-brand-gold transition-colors"
|
||||
value={formData.institutionId}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
institutionId: e.target.value,
|
||||
})
|
||||
}
|
||||
required
|
||||
>
|
||||
<option value="">Selecione uma universidade</option>
|
||||
{userInstitutions.map((inst) => (
|
||||
<option key={inst.id} value={inst.id}>
|
||||
{inst.name} - {inst.type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowInstitutionForm(true)}
|
||||
className="text-xs text-brand-gold hover:underline flex items-center"
|
||||
>
|
||||
<Plus size={12} className="mr-1" />
|
||||
Cadastrar nova universidade
|
||||
</button>
|
||||
|
||||
{formData.institutionId && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-sm p-3 flex items-center">
|
||||
<Check size={16} className="text-green-600 mr-2" />
|
||||
<span className="text-xs text-green-800">
|
||||
Universidade selecionada com sucesso
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cover Image Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1 tracking-wide uppercase text-xs">
|
||||
Imagem de Capa
|
||||
</label>
|
||||
<div className="relative border border-gray-300 rounded-sm p-2 flex items-center bg-white">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
onChange={(e) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
const file = e.target.files[0];
|
||||
const imageUrl = URL.createObjectURL(file);
|
||||
setFormData({ ...formData, coverImage: imageUrl });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-between w-full px-2">
|
||||
<span className="text-sm text-gray-500 truncate max-w-[200px]">
|
||||
{formData.coverImage &&
|
||||
!formData.coverImage.startsWith("http")
|
||||
? "Imagem selecionada"
|
||||
: formData.coverImage
|
||||
? "Imagem atual (URL)"
|
||||
: "Clique para selecionar..."}
|
||||
</span>
|
||||
<div className="bg-gray-100 p-1.5 rounded hover:bg-gray-200">
|
||||
<Upload size={16} className="text-gray-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{formData.coverImage && (
|
||||
<div className="mt-2 h-32 w-full rounded-sm overflow-hidden border border-gray-200 relative group">
|
||||
<img
|
||||
src={formData.coverImage}
|
||||
alt="Preview"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-white text-xs">
|
||||
Visualização da Capa
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-8">
|
||||
<Button onClick={() => setActiveTab("location")}>
|
||||
Próximo: Localização
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "location" && (
|
||||
<div className="space-y-6 fade-in">
|
||||
<div className="relative">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1 tracking-wide uppercase text-xs">
|
||||
Busca de Endereço (Powered by Mapbox)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-sm focus:outline-none focus:ring-1 focus:ring-brand-gold focus:border-brand-gold transition-colors pr-10"
|
||||
placeholder="Digite o nome do local ou endereço..."
|
||||
value={addressQuery}
|
||||
onChange={(e) => setAddressQuery(e.target.value)}
|
||||
/>
|
||||
<div className="absolute right-3 top-2.5 text-gray-400">
|
||||
{isSearching ? (
|
||||
<div className="animate-spin h-5 w-5 border-2 border-brand-gold rounded-full border-t-transparent"></div>
|
||||
) : (
|
||||
<Search size={20} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{addressResults.length > 0 && (
|
||||
<ul className="absolute z-10 w-full bg-white border mt-1 shadow-lg rounded-sm max-h-64 overflow-y-auto">
|
||||
{addressResults.map((addr, idx) => (
|
||||
<li
|
||||
key={idx}
|
||||
className="px-4 py-3 hover:bg-gray-50 cursor-pointer text-sm border-b border-gray-50 last:border-0"
|
||||
onClick={() => handleAddressSelect(addr)}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex items-start">
|
||||
<MapPin
|
||||
size={16}
|
||||
className="mt-0.5 mr-2 text-brand-gold flex-shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">
|
||||
{addr.description}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{addr.city}, {addr.state}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{addr.mapLink && (
|
||||
<span className="flex items-center text-[10px] text-blue-600 bg-blue-50 px-2 py-1 rounded ml-2">
|
||||
<img
|
||||
src="https://www.google.com/images/branding/product/ico/maps15_bnuw3a_32dp.png"
|
||||
alt="Maps"
|
||||
className="w-3 h-3 mr-1"
|
||||
/>
|
||||
Maps
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2">
|
||||
<Input
|
||||
label="Rua"
|
||||
value={formData.address.street}
|
||||
onChange={(e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
address: {
|
||||
...formData.address,
|
||||
street: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
onBlur={handleManualAddressChange}
|
||||
placeholder="Digite o nome da rua"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Número"
|
||||
placeholder="123"
|
||||
value={formData.address.number}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setFormData({
|
||||
...formData,
|
||||
address: { ...formData.address, number: value },
|
||||
});
|
||||
}}
|
||||
onBlur={handleManualAddressChange}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Cidade"
|
||||
value={formData.address.city}
|
||||
onChange={(e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
address: { ...formData.address, city: e.target.value },
|
||||
});
|
||||
}}
|
||||
onBlur={handleManualAddressChange}
|
||||
placeholder="Digite a cidade"
|
||||
/>
|
||||
<Input
|
||||
label="Estado"
|
||||
value={formData.address.state}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.toUpperCase().slice(0, 2);
|
||||
setFormData({
|
||||
...formData,
|
||||
address: { ...formData.address, state: value },
|
||||
});
|
||||
}}
|
||||
onBlur={handleManualAddressChange}
|
||||
placeholder="SP"
|
||||
maxLength={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mapa Interativo */}
|
||||
<div className="mt-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3 tracking-wide uppercase text-xs flex items-center justify-between">
|
||||
<span>📍 Mapa Interativo - Ajuste a Localização Exata</span>
|
||||
{isGeocoding && (
|
||||
<span className="text-xs text-brand-gold flex items-center normal-case">
|
||||
<div className="animate-spin h-3 w-3 border-2 border-brand-gold rounded-full border-t-transparent mr-2"></div>
|
||||
Localizando no mapa...
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<MapboxMap
|
||||
initialLat={formData.address.lat || -23.5505}
|
||||
initialLng={formData.address.lng || -46.6333}
|
||||
onLocationChange={handleMapLocationChange}
|
||||
height="450px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.address.mapLink && (
|
||||
<div className="bg-gray-50 p-3 rounded border border-gray-200 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500 flex items-center">
|
||||
<Check size={14} className="mr-1 text-green-500" />
|
||||
Localização verificada via Mapbox
|
||||
</span>
|
||||
<a
|
||||
href={formData.address.mapLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs text-brand-gold flex items-center hover:underline"
|
||||
>
|
||||
Ver no mapa <ExternalLink size={12} className="ml-1" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between mt-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setActiveTab("details")}
|
||||
>
|
||||
Voltar
|
||||
</Button>
|
||||
<Button onClick={() => setActiveTab("briefing")}>
|
||||
Próximo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "briefing" && (
|
||||
<div className="space-y-6 fade-in">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1 tracking-wide uppercase text-xs">
|
||||
{isClientRequest
|
||||
? "Conte-nos sobre o seu sonho"
|
||||
: "Briefing Técnico"}
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full border border-gray-300 rounded-sm p-3 focus:outline-none focus:border-brand-gold h-32 text-sm"
|
||||
placeholder={
|
||||
isClientRequest
|
||||
? "Qual o estilo do casamento? Quais fotos são indispensáveis? Fale um pouco sobre vocês..."
|
||||
: "Instruções técnicas..."
|
||||
}
|
||||
value={formData.briefing}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, briefing: e.target.value })
|
||||
}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<label className="text-sm font-medium text-gray-700 tracking-wide uppercase text-xs">
|
||||
Contatos / Responsáveis
|
||||
</label>
|
||||
<button
|
||||
onClick={addContact}
|
||||
className="text-xs text-brand-gold font-bold hover:underline flex items-center"
|
||||
>
|
||||
<Plus size={14} className="mr-1" /> Adicionar
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{formData.contacts.map((contact: any, idx: number) => (
|
||||
<div key={idx} className="flex space-x-2 items-start">
|
||||
<Input
|
||||
label={idx === 0 ? "Nome" : ""}
|
||||
placeholder="Nome"
|
||||
value={contact.name}
|
||||
onChange={(e) => {
|
||||
const newContacts = [...formData.contacts];
|
||||
newContacts[idx].name = e.target.value;
|
||||
setFormData({ ...formData, contacts: newContacts });
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
label={idx === 0 ? "Papel" : ""}
|
||||
placeholder="Ex: Cerimonialista"
|
||||
value={contact.role}
|
||||
onChange={(e) => {
|
||||
const newContacts = [...formData.contacts];
|
||||
newContacts[idx].role = e.target.value;
|
||||
setFormData({ ...formData, contacts: newContacts });
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeContact(idx)}
|
||||
className={`mt-1 p-2 text-gray-400 hover:text-red-500 ${
|
||||
idx === 0 ? "mt-7" : ""
|
||||
}`}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setActiveTab("location")}
|
||||
>
|
||||
Voltar
|
||||
</Button>
|
||||
<Button onClick={() => setActiveTab("files")}>Próximo</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "files" && (
|
||||
<div className="space-y-6 fade-in">
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-10 text-center hover:bg-gray-50 transition-colors relative">
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
<Upload size={40} className="mx-auto text-gray-400 mb-4" />
|
||||
<p className="text-sm text-gray-600 font-medium">
|
||||
{isClientRequest
|
||||
? "Anexe referências visuais (Moodboard)"
|
||||
: "Anexe contratos e cronogramas"}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
PDF, JPG, PNG (Max 10MB)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{formData.files.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-gray-700">
|
||||
Arquivos Selecionados:
|
||||
</h4>
|
||||
{formData.files.map((file: any, idx: number) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded border border-gray-100"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<FileText size={18} className="text-brand-gold mr-3" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{file.name}</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{(file.size / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Check size={16} className="text-green-500" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between mt-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setActiveTab("briefing")}
|
||||
>
|
||||
Voltar
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} variant="secondary">
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
120
frontend/components/Input.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label: string;
|
||||
error?: string;
|
||||
mask?: 'phone' | 'cnpj' | 'cep';
|
||||
}
|
||||
|
||||
export const Input: React.FC<InputProps> = ({ label, error, className = '', type, mask, onChange, ...props }) => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const isPassword = type === 'password';
|
||||
const inputType = isPassword && showPassword ? 'text' : type;
|
||||
|
||||
const applyMask = (value: string, maskType?: 'phone' | 'cnpj' | 'cep') => {
|
||||
if (!maskType) return value;
|
||||
|
||||
const numbers = value.replace(/\D/g, '');
|
||||
|
||||
switch (maskType) {
|
||||
case 'phone':
|
||||
// Limita a 11 dígitos (celular)
|
||||
const phoneNumbers = numbers.slice(0, 11);
|
||||
if (phoneNumbers.length <= 10) {
|
||||
return phoneNumbers.replace(/(\d{2})(\d{4})(\d{0,4})/, '($1) $2-$3');
|
||||
}
|
||||
return phoneNumbers.replace(/(\d{2})(\d{5})(\d{0,4})/, '($1) $2-$3');
|
||||
|
||||
case 'cnpj':
|
||||
// Limita a 14 dígitos
|
||||
const cnpjNumbers = numbers.slice(0, 14);
|
||||
return cnpjNumbers.replace(/(\d{2})(\d{3})(\d{3})(\d{4})(\d{0,2})/, '$1.$2.$3/$4-$5');
|
||||
|
||||
case 'cep':
|
||||
// Limita a 8 dígitos
|
||||
const cepNumbers = numbers.slice(0, 8);
|
||||
return cepNumbers.replace(/(\d{5})(\d{0,3})/, '$1-$2');
|
||||
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (mask) {
|
||||
const maskedValue = applyMask(e.target.value, mask);
|
||||
e.target.value = maskedValue;
|
||||
}
|
||||
onChange?.(e);
|
||||
};
|
||||
|
||||
// Define maxLength baseado na máscara
|
||||
const getMaxLength = () => {
|
||||
if (!mask) return undefined;
|
||||
switch (mask) {
|
||||
case 'phone': return 15; // (00) 00000-0000
|
||||
case 'cnpj': return 18; // 00.000.000/0000-00
|
||||
case 'cep': return 9; // 00000-000
|
||||
default: return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1 tracking-wide uppercase text-xs">
|
||||
{label}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
className={`w-full px-4 py-2 border rounded-sm focus:outline-none focus:ring-1 focus:ring-brand-gold focus:border-brand-gold transition-colors
|
||||
${error ? 'border-red-500' : 'border-gray-300'}
|
||||
${isPassword ? 'pr-10' : ''}
|
||||
${className}`}
|
||||
type={inputType}
|
||||
onChange={handleChange}
|
||||
maxLength={getMaxLength()}
|
||||
{...props}
|
||||
/>
|
||||
{isPassword && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && <span className="text-xs text-red-500 mt-1">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label: string;
|
||||
options: { value: string; label: string }[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Select: React.FC<SelectProps> = ({ label, options, error, className = '', ...props }) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1 tracking-wide uppercase text-xs">
|
||||
{label}
|
||||
</label>
|
||||
<select
|
||||
className={`w-full px-4 py-2 border rounded-sm focus:outline-none focus:ring-1 focus:ring-brand-gold focus:border-brand-gold transition-colors appearance-none bg-white
|
||||
${error ? 'border-red-500' : 'border-gray-300'}
|
||||
${className}`}
|
||||
{...props}
|
||||
>
|
||||
<option value="" disabled>Selecione uma opção</option>
|
||||
{options.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{error && <span className="text-xs text-red-500 mt-1">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
248
frontend/components/InstitutionForm.tsx
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
|
||||
import React, { useState } from 'react';
|
||||
import { Institution, Address } from '../types';
|
||||
import { Input, Select } from './Input';
|
||||
import { Button } from './Button';
|
||||
import { Building2, X, Check } from 'lucide-react';
|
||||
|
||||
interface InstitutionFormProps {
|
||||
onCancel: () => void;
|
||||
onSubmit: (data: Partial<Institution>) => void;
|
||||
initialData?: Institution;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
const INSTITUTION_TYPES = [
|
||||
'Universidade Pública',
|
||||
'Universidade Privada',
|
||||
'Faculdade',
|
||||
'Instituto Federal',
|
||||
'Centro Universitário',
|
||||
'Campus Universitário'
|
||||
];
|
||||
|
||||
export const InstitutionForm: React.FC<InstitutionFormProps> = ({
|
||||
onCancel,
|
||||
onSubmit,
|
||||
initialData,
|
||||
userId
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<Partial<Institution>>(initialData || {
|
||||
name: '',
|
||||
type: '',
|
||||
cnpj: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
description: '',
|
||||
ownerId: userId,
|
||||
address: {
|
||||
street: '',
|
||||
number: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zip: ''
|
||||
}
|
||||
});
|
||||
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
const [stateError, setStateError] = useState('');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setShowToast(true);
|
||||
setTimeout(() => {
|
||||
onSubmit(formData);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const handleChange = (field: keyof Institution, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleAddressChange = (field: keyof Address, value: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
address: {
|
||||
...prev.address!,
|
||||
[field]: value
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-xl overflow-hidden max-w-2xl mx-auto border border-gray-100 slide-up relative">
|
||||
|
||||
{/* Success Toast */}
|
||||
{showToast && (
|
||||
<div className="absolute top-4 right-4 z-50 bg-brand-black text-white px-6 py-4 rounded shadow-2xl flex items-center space-x-3 fade-in">
|
||||
<Check className="text-brand-gold h-6 w-6" />
|
||||
<div>
|
||||
<h4 className="font-bold text-sm">Sucesso!</h4>
|
||||
<p className="text-xs text-gray-300">Universidade cadastrada com sucesso.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form Header */}
|
||||
<div className="bg-gray-50 border-b px-8 py-6 flex justify-between items-center">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Building2 className="text-brand-gold h-8 w-8" />
|
||||
<div>
|
||||
<h2 className="text-2xl font-serif text-brand-black">
|
||||
{initialData ? 'Editar Universidade' : 'Cadastrar Universidade'}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Registre a universidade onde os eventos fotográficos serão realizados
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="p-2 hover:bg-gray-200 rounded-full transition-colors"
|
||||
>
|
||||
<X size={20} className="text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-8 space-y-6">
|
||||
|
||||
{/* Informações Básicas */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 tracking-wide uppercase">
|
||||
Informações Básicas
|
||||
</h3>
|
||||
|
||||
<Input
|
||||
label="Nome da Universidade*"
|
||||
placeholder="Ex: Universidade Federal do Rio Grande do Sul"
|
||||
value={formData.name || ''}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Select
|
||||
label="Tipo de Universidade*"
|
||||
options={INSTITUTION_TYPES.map(t => ({ value: t, label: t }))}
|
||||
value={formData.type || ''}
|
||||
onChange={(e) => handleChange('type', e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="CNPJ (Opcional)"
|
||||
placeholder="00.000.000/0000-00"
|
||||
value={formData.cnpj || ''}
|
||||
onChange={(e) => handleChange('cnpj', e.target.value)}
|
||||
mask="cnpj"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Telefone*"
|
||||
type="tel"
|
||||
placeholder="(00) 00000-0000"
|
||||
value={formData.phone || ''}
|
||||
onChange={(e) => handleChange('phone', e.target.value)}
|
||||
mask="phone"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="E-mail*"
|
||||
type="email"
|
||||
placeholder="contato@instituicao.com"
|
||||
value={formData.email || ''}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1 tracking-wide uppercase text-xs">
|
||||
Descrição (Opcional)
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full border border-gray-300 rounded-sm p-3 focus:outline-none focus:border-brand-gold h-24 text-sm"
|
||||
placeholder="Ex: Campus principal, informações sobre o campus, áreas para eventos..."
|
||||
value={formData.description || ''}
|
||||
onChange={(e) => handleChange('description', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Endereço */}
|
||||
<div className="space-y-4 border-t pt-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 tracking-wide uppercase">
|
||||
Endereço (Opcional)
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2">
|
||||
<Input
|
||||
label="Rua"
|
||||
placeholder="Nome da rua"
|
||||
value={formData.address?.street || ''}
|
||||
onChange={(e) => handleAddressChange('street', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Número"
|
||||
placeholder="123"
|
||||
value={formData.address?.number || ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.replace(/\D/g, '');
|
||||
handleAddressChange('number', value);
|
||||
}}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Input
|
||||
label="Cidade"
|
||||
placeholder="Cidade"
|
||||
value={formData.address?.city || ''}
|
||||
onChange={(e) => handleAddressChange('city', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Estado"
|
||||
placeholder="UF"
|
||||
value={formData.address?.state || ''}
|
||||
onChange={(e) => {
|
||||
const hasNumbers = /[0-9]/.test(e.target.value);
|
||||
if (hasNumbers) {
|
||||
setStateError('O campo Estado aceita apenas letras');
|
||||
setTimeout(() => setStateError(''), 3000);
|
||||
}
|
||||
const value = e.target.value.replace(/[0-9]/g, '').toUpperCase();
|
||||
handleAddressChange('state', value);
|
||||
}}
|
||||
maxLength={2}
|
||||
error={stateError}
|
||||
/>
|
||||
<Input
|
||||
label="CEP"
|
||||
placeholder="00000-000"
|
||||
value={formData.address?.zip || ''}
|
||||
onChange={(e) => handleAddressChange('zip', e.target.value)}
|
||||
mask="cep"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end space-x-3 pt-6 border-t">
|
||||
<Button variant="outline" onClick={onCancel} type="button">
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" variant="secondary">
|
||||
{initialData ? 'Salvar Alterações' : 'Cadastrar Universidade'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
225
frontend/components/MapboxMap.tsx
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import React, { useEffect, useRef, useState } from "react";
|
||||
import mapboxgl from "mapbox-gl";
|
||||
import "mapbox-gl/dist/mapbox-gl.css";
|
||||
import { MapPin, Target } from "lucide-react";
|
||||
|
||||
interface MapboxMapProps {
|
||||
initialLat?: number;
|
||||
initialLng?: number;
|
||||
onLocationChange?: (lat: number, lng: number) => void;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
export const MapboxMap: React.FC<MapboxMapProps> = ({
|
||||
initialLat = -23.5505, // São Paulo como padrão
|
||||
initialLng = -46.6333,
|
||||
onLocationChange,
|
||||
height = "400px",
|
||||
}) => {
|
||||
const mapContainer = useRef<HTMLDivElement>(null);
|
||||
const map = useRef<mapboxgl.Map | null>(null);
|
||||
const marker = useRef<mapboxgl.Marker | null>(null);
|
||||
const [currentLat, setCurrentLat] = useState(initialLat);
|
||||
const [currentLng, setCurrentLng] = useState(initialLng);
|
||||
const [mapLoaded, setMapLoaded] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapContainer.current || map.current) return;
|
||||
|
||||
try {
|
||||
console.log("🗺️ Inicializando mapa Mapbox...");
|
||||
// Configurar token
|
||||
const token = import.meta.env.VITE_MAPBOX_TOKEN;
|
||||
if (!token) {
|
||||
setError(
|
||||
"❌ Token do Mapbox não encontrado. Configure VITE_MAPBOX_TOKEN no arquivo .env.local"
|
||||
);
|
||||
return;
|
||||
}
|
||||
mapboxgl.accessToken = token;
|
||||
|
||||
// Inicializar mapa
|
||||
map.current = new mapboxgl.Map({
|
||||
container: mapContainer.current,
|
||||
style: "mapbox://styles/mapbox/streets-v12",
|
||||
center: [initialLng, initialLat],
|
||||
zoom: 15,
|
||||
});
|
||||
|
||||
console.log("✅ Mapa criado com sucesso");
|
||||
|
||||
// Adicionar controles de navegação
|
||||
map.current.addControl(new mapboxgl.NavigationControl(), "top-right");
|
||||
|
||||
// Adicionar controle de localização
|
||||
map.current.addControl(
|
||||
new mapboxgl.GeolocateControl({
|
||||
positionOptions: {
|
||||
enableHighAccuracy: true,
|
||||
},
|
||||
trackUserLocation: true,
|
||||
showUserHeading: true,
|
||||
}),
|
||||
"top-right"
|
||||
);
|
||||
|
||||
// Criar marcador arrastável
|
||||
marker.current = new mapboxgl.Marker({
|
||||
color: "#c5a059",
|
||||
draggable: true,
|
||||
})
|
||||
.setLngLat([initialLng, initialLat])
|
||||
.addTo(map.current);
|
||||
|
||||
// Evento quando o marcador é arrastado
|
||||
marker.current.on("dragend", () => {
|
||||
if (marker.current) {
|
||||
const lngLat = marker.current.getLngLat();
|
||||
setCurrentLat(lngLat.lat);
|
||||
setCurrentLng(lngLat.lng);
|
||||
if (onLocationChange) {
|
||||
onLocationChange(lngLat.lat, lngLat.lng);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Evento de clique no mapa para mover o marcador
|
||||
map.current.on("click", (e) => {
|
||||
if (marker.current) {
|
||||
marker.current.setLngLat([e.lngLat.lng, e.lngLat.lat]);
|
||||
setCurrentLat(e.lngLat.lat);
|
||||
setCurrentLng(e.lngLat.lng);
|
||||
if (onLocationChange) {
|
||||
onLocationChange(e.lngLat.lat, e.lngLat.lng);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
map.current.on("load", () => {
|
||||
setMapLoaded(true);
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Erro ao inicializar mapa:", error);
|
||||
const errorMsg = error?.message || String(error);
|
||||
if (
|
||||
errorMsg.includes("token") ||
|
||||
errorMsg.includes("Unauthorized") ||
|
||||
errorMsg.includes("401")
|
||||
) {
|
||||
setError(
|
||||
"❌ Token do Mapbox inválido. Obtenha um token gratuito em https://account.mapbox.com/ e configure em services/mapboxService.ts"
|
||||
);
|
||||
} else {
|
||||
setError(`Erro ao carregar o mapa: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (marker.current) {
|
||||
marker.current.remove();
|
||||
}
|
||||
if (map.current) {
|
||||
map.current.remove();
|
||||
map.current = null;
|
||||
}
|
||||
};
|
||||
}, []); // Executar apenas uma vez
|
||||
|
||||
// Atualizar posição do marcador quando as coordenadas mudarem externamente
|
||||
useEffect(() => {
|
||||
if (marker.current && map.current && mapLoaded) {
|
||||
marker.current.setLngLat([initialLng, initialLat]);
|
||||
map.current.flyTo({
|
||||
center: [initialLng, initialLat],
|
||||
zoom: 15,
|
||||
duration: 1500,
|
||||
});
|
||||
setCurrentLat(initialLat);
|
||||
setCurrentLng(initialLng);
|
||||
}
|
||||
}, [initialLat, initialLng, mapLoaded]);
|
||||
|
||||
const centerOnMarker = () => {
|
||||
if (map.current && marker.current) {
|
||||
const lngLat = marker.current.getLngLat();
|
||||
map.current.flyTo({
|
||||
center: [lngLat.lng, lngLat.lat],
|
||||
zoom: 17,
|
||||
duration: 1000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{error && (
|
||||
<div className="absolute top-0 left-0 right-0 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded z-10">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={mapContainer}
|
||||
className="w-full rounded-lg border-2 border-gray-300 overflow-hidden shadow-md"
|
||||
style={{ height }}
|
||||
/>
|
||||
|
||||
{/* Info overlay - Responsivo */}
|
||||
<div className="absolute bottom-2 sm:bottom-4 left-2 sm:left-4 bg-white/95 backdrop-blur-sm px-2 py-2 sm:px-4 sm:py-3 rounded-lg shadow-lg border border-gray-200 max-w-[160px] sm:max-w-none">
|
||||
<div className="flex items-center space-x-2 sm:space-x-3">
|
||||
<MapPin
|
||||
size={16}
|
||||
className="text-brand-gold flex-shrink-0 hidden sm:block"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] sm:text-xs text-gray-500 font-medium hidden sm:block">
|
||||
Coordenadas
|
||||
</p>
|
||||
<p className="text-[10px] sm:text-sm font-mono text-gray-800 truncate">
|
||||
{currentLat.toFixed(4)}, {currentLng.toFixed(4)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Botão de centralizar - Responsivo */}
|
||||
<button
|
||||
onClick={centerOnMarker}
|
||||
className="absolute bottom-2 sm:bottom-4 right-2 sm:right-4 bg-white hover:bg-gray-50 p-2 sm:p-3 rounded-full shadow-lg border border-gray-200 transition-colors group"
|
||||
title="Centralizar no marcador"
|
||||
>
|
||||
<Target
|
||||
size={18}
|
||||
className="text-gray-600 group-hover:text-brand-gold transition-colors sm:w-5 sm:h-5"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Instruções - Responsivo */}
|
||||
<div className="mt-3 bg-blue-50 border border-blue-200 rounded-lg p-2 sm:p-3 text-xs sm:text-sm text-blue-800">
|
||||
<p className="font-medium mb-1 text-xs sm:text-sm">💡 Como usar:</p>
|
||||
<ul className="text-[11px] sm:text-xs space-y-0.5 sm:space-y-1 text-blue-700">
|
||||
<li className="flex items-start">
|
||||
<span className="mr-1">•</span>
|
||||
<span>
|
||||
<strong>Arraste o marcador</strong> para a posição exata
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="mr-1">•</span>
|
||||
<span>
|
||||
<strong>Clique no mapa</strong> para mover o marcador
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start hidden sm:flex">
|
||||
<span className="mr-1">•</span>
|
||||
<span>
|
||||
Use os <strong>controles</strong> para zoom e navegação
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
255
frontend/components/Navbar.tsx
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { UserRole } from "../types";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { Menu, X, LogOut, User } from "lucide-react";
|
||||
import { Button } from "./Button";
|
||||
|
||||
interface NavbarProps {
|
||||
onNavigate: (page: string) => void;
|
||||
currentPage: string;
|
||||
}
|
||||
|
||||
export const Navbar: React.FC<NavbarProps> = ({ onNavigate, currentPage }) => {
|
||||
const { user, logout } = useAuth();
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [isAccountDropdownOpen, setIsAccountDropdownOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(window.scrollY > 20);
|
||||
};
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
const getLinks = () => {
|
||||
if (!user) return [];
|
||||
|
||||
switch (user.role) {
|
||||
case UserRole.SUPERADMIN:
|
||||
case UserRole.BUSINESS_OWNER:
|
||||
return [
|
||||
{ name: "Gestão de Eventos", path: "dashboard" },
|
||||
{ name: "Equipe & Fotógrafos", path: "team" },
|
||||
{ name: "Financeiro", path: "finance" },
|
||||
{ name: "Configurações", path: "settings" },
|
||||
];
|
||||
case UserRole.EVENT_OWNER:
|
||||
return [
|
||||
{ name: "Meus Eventos", path: "dashboard" },
|
||||
{ name: "Solicitar Evento", path: "request-event" },
|
||||
];
|
||||
case UserRole.PHOTOGRAPHER:
|
||||
return [
|
||||
{ name: "Eventos Designados", path: "dashboard" },
|
||||
{ name: "Agenda", path: "calendar" },
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleLabel = () => {
|
||||
if (!user) return "";
|
||||
if (user.role === UserRole.BUSINESS_OWNER) return "Empresa";
|
||||
if (user.role === UserRole.EVENT_OWNER) return "Cliente";
|
||||
if (user.role === UserRole.PHOTOGRAPHER) return "Fotógrafo";
|
||||
if (user.role === UserRole.SUPERADMIN) return "Super Admin";
|
||||
return "";
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="fixed w-full z-50 bg-white shadow-sm py-3">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
{/* Logo */}
|
||||
<div
|
||||
className="flex-shrink-0 flex items-center cursor-pointer"
|
||||
onClick={() => onNavigate("home")}
|
||||
>
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="Photum Formaturas"
|
||||
className="h-30 mb-6 w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
{user && (
|
||||
<div className="hidden md:flex items-center space-x-6">
|
||||
{getLinks().map((link) => (
|
||||
<button
|
||||
key={link.path}
|
||||
onClick={() => onNavigate(link.path)}
|
||||
className={`text-sm font-medium tracking-wide uppercase hover:text-brand-gold transition-colors pb-1 ${
|
||||
currentPage === link.path
|
||||
? "text-brand-gold border-b-2 border-brand-gold"
|
||||
: "text-gray-600 border-b-2 border-transparent"
|
||||
}`}
|
||||
>
|
||||
{link.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right Side Actions */}
|
||||
<div className="hidden md:flex items-center space-x-4">
|
||||
{user ? (
|
||||
<div className="flex items-center space-x-4 pl-4 border-l border-gray-200">
|
||||
<div className="flex flex-col items-end mr-2">
|
||||
<span className="text-sm font-bold text-brand-black leading-tight">
|
||||
{user.name}
|
||||
</span>
|
||||
<span className="text-[10px] uppercase tracking-wider text-brand-gold leading-tight">
|
||||
{getRoleLabel()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-9 w-9 rounded-full bg-gray-100 overflow-hidden border border-gray-200 ring-2 ring-transparent hover:ring-brand-gold transition-all">
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt="Avatar"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-gray-400 hover:text-red-500 transition-colors p-1"
|
||||
title="Sair"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() =>
|
||||
setIsAccountDropdownOpen(!isAccountDropdownOpen)
|
||||
}
|
||||
className="flex items-center gap-2 p-2 rounded-full hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full border-2 border-brand-gold flex items-center justify-center text-brand-gold hover:bg-brand-gold hover:text-white transition-colors">
|
||||
<User size={24} />
|
||||
</div>
|
||||
<div className="text-left hidden lg:block">
|
||||
<p className="text-xs text-gray-500">Olá, bem-vindo(a)</p>
|
||||
<p className="text-sm font-medium text-brand-black">
|
||||
Entrar / Cadastrar
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Dropdown Popup - Centralizado */}
|
||||
{isAccountDropdownOpen && (
|
||||
<div className="absolute left-1/2 -translate-x-1/2 top-full mt-2 w-64 bg-white rounded-xl shadow-xl border border-gray-200 overflow-hidden z-50 fade-in">
|
||||
<div className="p-4 space-y-3">
|
||||
<Button
|
||||
onClick={() => {
|
||||
onNavigate("login");
|
||||
setIsAccountDropdownOpen(false);
|
||||
}}
|
||||
variant="secondary"
|
||||
className="w-full rounded-xl"
|
||||
>
|
||||
ENTRAR
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
onNavigate("register");
|
||||
setIsAccountDropdownOpen(false);
|
||||
}}
|
||||
className="w-full bg-purple-600 text-white hover:bg-purple-700 focus:ring-purple-500 rounded-xl"
|
||||
>
|
||||
Cadastre-se agora
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile Button */}
|
||||
<div className="md:hidden flex items-center">
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
className="text-brand-black hover:text-brand-gold p-2"
|
||||
>
|
||||
{isMobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{isMobileMenuOpen && (
|
||||
<div className="md:hidden absolute top-full left-0 w-full bg-white border-b border-gray-100 shadow-lg fade-in">
|
||||
<div className="px-4 py-4 space-y-3">
|
||||
{user &&
|
||||
getLinks().map((link) => (
|
||||
<button
|
||||
key={link.path}
|
||||
onClick={() => {
|
||||
onNavigate(link.path);
|
||||
setIsMobileMenuOpen(false);
|
||||
}}
|
||||
className="block w-full text-left text-base font-medium text-gray-700 hover:text-brand-gold py-2 border-b border-gray-50"
|
||||
>
|
||||
{link.name}
|
||||
</button>
|
||||
))}
|
||||
<div className="pt-4">
|
||||
{user ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<img
|
||||
src={user.avatar}
|
||||
className="w-8 h-8 rounded-full mr-2"
|
||||
/>
|
||||
<div>
|
||||
<span className="font-bold text-sm block">
|
||||
{user.name}
|
||||
</span>
|
||||
<span className="text-xs text-brand-gold">
|
||||
{getRoleLabel()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={logout}>
|
||||
Sair
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
className="w-full rounded-lg"
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
onNavigate("login");
|
||||
setIsMobileMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
ENTRAR
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full bg-purple-600 text-white hover:bg-purple-700 focus:ring-purple-500 rounded-lg"
|
||||
size="lg"
|
||||
onClick={() => {
|
||||
onNavigate("register");
|
||||
setIsMobileMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
Cadastre-se agora
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
179
frontend/contexts/DataContext.tsx
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import React, { createContext, useContext, useState, ReactNode } from "react";
|
||||
import { EventData, EventStatus, EventType, Institution } from "../types";
|
||||
|
||||
// Initial Mock Data
|
||||
const INITIAL_INSTITUTIONS: Institution[] = [
|
||||
{
|
||||
id: "inst-1",
|
||||
name: "Universidade Federal do Rio Grande do Sul",
|
||||
type: "Universidade Pública",
|
||||
phone: "(51) 3308-3333",
|
||||
email: "eventos@ufrgs.br",
|
||||
address: {
|
||||
street: "Av. Paulo Gama",
|
||||
number: "110",
|
||||
city: "Porto Alegre",
|
||||
state: "RS",
|
||||
zip: "90040-060",
|
||||
},
|
||||
description:
|
||||
"Campus Central - Principais eventos realizados no Salão de Atos",
|
||||
ownerId: "client-1",
|
||||
},
|
||||
];
|
||||
|
||||
const INITIAL_EVENTS: EventData[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Casamento Juliana & Marcos",
|
||||
date: "2024-10-15",
|
||||
time: "16:00",
|
||||
type: EventType.WEDDING,
|
||||
status: EventStatus.CONFIRMED,
|
||||
address: {
|
||||
street: "Av. das Hortênsias",
|
||||
number: "1200",
|
||||
city: "Gramado",
|
||||
state: "RS",
|
||||
zip: "95670-000",
|
||||
},
|
||||
briefing:
|
||||
"Cerimônia ao pôr do sol. Foco em fotos espontâneas dos noivos e pais.",
|
||||
coverImage: "https://picsum.photos/id/1059/800/400",
|
||||
contacts: [
|
||||
{
|
||||
id: "c1",
|
||||
name: "Cerimonial Silva",
|
||||
role: "Cerimonialista",
|
||||
phone: "9999-9999",
|
||||
email: "c@teste.com",
|
||||
},
|
||||
],
|
||||
checklist: [],
|
||||
ownerId: "client-1",
|
||||
photographerIds: ["photographer-1"],
|
||||
institutionId: "inst-1",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Conferência Tech Innovators",
|
||||
date: "2024-11-05",
|
||||
time: "08:00",
|
||||
type: EventType.CORPORATE,
|
||||
status: EventStatus.PENDING_APPROVAL,
|
||||
address: {
|
||||
street: "Rua Olimpíadas",
|
||||
number: "205",
|
||||
city: "São Paulo",
|
||||
state: "SP",
|
||||
zip: "04551-000",
|
||||
},
|
||||
briefing: "Cobrir palestras principais e networking no coffee break.",
|
||||
coverImage: "https://picsum.photos/id/3/800/400",
|
||||
contacts: [],
|
||||
checklist: [],
|
||||
ownerId: "client-2", // Other client
|
||||
photographerIds: [],
|
||||
},
|
||||
];
|
||||
|
||||
interface DataContextType {
|
||||
events: EventData[];
|
||||
institutions: Institution[];
|
||||
addEvent: (event: EventData) => void;
|
||||
updateEventStatus: (id: string, status: EventStatus) => void;
|
||||
assignPhotographer: (eventId: string, photographerId: string) => void;
|
||||
getEventsByRole: (userId: string, role: string) => EventData[];
|
||||
addInstitution: (institution: Institution) => void;
|
||||
updateInstitution: (id: string, institution: Partial<Institution>) => void;
|
||||
getInstitutionsByUserId: (userId: string) => Institution[];
|
||||
getInstitutionById: (id: string) => Institution | undefined;
|
||||
}
|
||||
|
||||
const DataContext = createContext<DataContextType | undefined>(undefined);
|
||||
|
||||
export const DataProvider: React.FC<{ children: ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [events, setEvents] = useState<EventData[]>(INITIAL_EVENTS);
|
||||
const [institutions, setInstitutions] =
|
||||
useState<Institution[]>(INITIAL_INSTITUTIONS);
|
||||
|
||||
const addEvent = (event: EventData) => {
|
||||
setEvents((prev) => [event, ...prev]);
|
||||
};
|
||||
|
||||
const updateEventStatus = (id: string, status: EventStatus) => {
|
||||
setEvents((prev) => prev.map((e) => (e.id === id ? { ...e, status } : e)));
|
||||
};
|
||||
|
||||
const assignPhotographer = (eventId: string, photographerId: string) => {
|
||||
setEvents((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id === eventId) {
|
||||
const current = e.photographerIds || [];
|
||||
if (!current.includes(photographerId)) {
|
||||
return { ...e, photographerIds: [...current, photographerId] };
|
||||
}
|
||||
}
|
||||
return e;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const getEventsByRole = (userId: string, role: string) => {
|
||||
if (role === "SUPERADMIN" || role === "BUSINESS_OWNER") {
|
||||
return events;
|
||||
}
|
||||
if (role === "EVENT_OWNER") {
|
||||
return events.filter((e) => e.ownerId === userId);
|
||||
}
|
||||
if (role === "PHOTOGRAPHER") {
|
||||
return events.filter((e) => e.photographerIds.includes(userId));
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const addInstitution = (institution: Institution) => {
|
||||
setInstitutions((prev) => [...prev, institution]);
|
||||
};
|
||||
|
||||
const updateInstitution = (id: string, updatedData: Partial<Institution>) => {
|
||||
setInstitutions((prev) =>
|
||||
prev.map((inst) => (inst.id === id ? { ...inst, ...updatedData } : inst))
|
||||
);
|
||||
};
|
||||
|
||||
const getInstitutionsByUserId = (userId: string) => {
|
||||
return institutions.filter((inst) => inst.ownerId === userId);
|
||||
};
|
||||
|
||||
const getInstitutionById = (id: string) => {
|
||||
return institutions.find((inst) => inst.id === id);
|
||||
};
|
||||
|
||||
return (
|
||||
<DataContext.Provider
|
||||
value={{
|
||||
events,
|
||||
institutions,
|
||||
addEvent,
|
||||
updateEventStatus,
|
||||
assignPhotographer,
|
||||
getEventsByRole,
|
||||
addInstitution,
|
||||
updateInstitution,
|
||||
getInstitutionsByUserId,
|
||||
getInstitutionById,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</DataContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useData = () => {
|
||||
const context = useContext(DataContext);
|
||||
if (!context) throw new Error("useData must be used within a DataProvider");
|
||||
return context;
|
||||
};
|
||||
249
frontend/index.html
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PhotumFormaturas</title>
|
||||
|
||||
<!-- Mapbox GL CSS (versão CDN confiável) -->
|
||||
<link href='https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css' rel='stylesheet' />
|
||||
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Playfair+Display:ital,wght@0,400;0,600;1,400&display=swap"
|
||||
rel="stylesheet">
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'sans-serif'],
|
||||
serif: ['Playfair Display', 'serif'],
|
||||
},
|
||||
colors: {
|
||||
brand: {
|
||||
black: '#1a1a1a',
|
||||
gold: '#B9CF33',
|
||||
gray: '#f4f4f4',
|
||||
darkgray: '#333333'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #B9CF33;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a5bd2e;
|
||||
}
|
||||
|
||||
/* Loading Screen */
|
||||
#loading-screen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #492E61 0%, #6B4694 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
animation: fadeOut 0.5s ease-out 2.5s forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
to {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-logo {
|
||||
width: 200px;
|
||||
height: auto;
|
||||
margin-bottom: 30px;
|
||||
animation: scaleIn 0.8s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
font-weight: 300;
|
||||
letter-spacing: 3px;
|
||||
margin-bottom: 20px;
|
||||
animation: fadeInUp 0.8s ease-out 0.3s both;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-bar {
|
||||
width: 200px;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
animation: fadeInUp 0.8s ease-out 0.5s both;
|
||||
}
|
||||
|
||||
.loading-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #B9CF33, #C2388B);
|
||||
border-radius: 10px;
|
||||
animation: loadingProgress 2s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes loadingProgress {
|
||||
from {
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
.slide-up {
|
||||
animation: slideUp 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Page entrance animation */
|
||||
#root {
|
||||
animation: pageEntrance 0.5s ease-out 2.5s both;
|
||||
}
|
||||
|
||||
@keyframes pageEntrance {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mapbox custom styles */
|
||||
.mapboxgl-ctrl-group {
|
||||
border-radius: 8px !important;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
.mapboxgl-ctrl-group button {
|
||||
width: 36px !important;
|
||||
height: 36px !important;
|
||||
}
|
||||
|
||||
.mapboxgl-ctrl-geolocate {
|
||||
background-color: white !important;
|
||||
}
|
||||
|
||||
.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='%23333' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 2v20M2 12h20'/%3E%3Ccircle cx='12' cy='12' r='4'/%3E%3C/svg%3E") !important;
|
||||
}
|
||||
|
||||
.custom-marker {
|
||||
cursor: grab !important;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.custom-marker:active {
|
||||
cursor: grabbing !important;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.mapboxgl-popup {
|
||||
max-width: 300px !important;
|
||||
}
|
||||
|
||||
.mapboxgl-popup-content {
|
||||
border-radius: 8px !important;
|
||||
padding: 15px !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-white text-brand-black antialiased selection:bg-brand-gold selection:text-white">
|
||||
<!-- Loading Screen -->
|
||||
<div id="loading-screen">
|
||||
<img src="/logo.png" alt="Photum Formaturas" class="loading-logo" />
|
||||
<div class="loading-text">PHOTUM FORMATURAS</div>
|
||||
<div class="loading-bar">
|
||||
<div class="loading-bar-fill"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
15
frontend/index.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) {
|
||||
throw new Error("Could not find root element");
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "PhotumManager",
|
||||
"name": "PhotumFormaturas",
|
||||
"description": "Sistema de gerenciamento de eventos premium inspirado na identidade visual do Photum.com.br. Foco em experiência do usuário, design minimalista e funcionalidades robustas para fotógrafos e donos de eventos.",
|
||||
"requestFramePermissions": [
|
||||
"geolocation"
|
||||
2924
frontend/package-lock.json
generated
Normal 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
|
|
@ -0,0 +1,583 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Calendar, Clock, MapPin, User, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface Event {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string;
|
||||
time: string;
|
||||
location: string;
|
||||
client: string;
|
||||
status: 'confirmed' | 'pending' | 'completed';
|
||||
type: 'formatura' | 'casamento' | 'evento';
|
||||
}
|
||||
|
||||
const MOCK_EVENTS: Event[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Formatura Medicina UFPR',
|
||||
date: '2025-12-15',
|
||||
time: '19:00',
|
||||
location: 'Teatro Guaíra, Curitiba',
|
||||
client: 'Ana Paula Silva',
|
||||
status: 'confirmed',
|
||||
type: 'formatura'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Casamento Maria & João',
|
||||
date: '2025-12-20',
|
||||
time: '16:00',
|
||||
location: 'Fazenda Vista Alegre',
|
||||
client: 'Maria Santos',
|
||||
status: 'confirmed',
|
||||
type: 'casamento'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Formatura Direito PUC',
|
||||
date: '2025-12-22',
|
||||
time: '20:00',
|
||||
location: 'Centro de Convenções',
|
||||
client: 'Carlos Eduardo',
|
||||
status: 'pending',
|
||||
type: 'formatura'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Formatura Engenharia UTFPR',
|
||||
date: '2025-12-28',
|
||||
time: '18:30',
|
||||
location: 'Espaço Nobre Eventos',
|
||||
client: 'Roberto Mendes',
|
||||
status: 'confirmed',
|
||||
type: 'formatura'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'Evento Corporativo Tech Summit',
|
||||
date: '2026-01-10',
|
||||
time: '09:00',
|
||||
location: 'Hotel Bourbon',
|
||||
client: 'TechCorp Ltda',
|
||||
status: 'pending',
|
||||
type: 'evento'
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: 'Formatura Odontologia',
|
||||
date: '2026-01-15',
|
||||
time: '19:30',
|
||||
location: 'Clube Curitibano',
|
||||
client: 'Juliana Costa',
|
||||
status: 'confirmed',
|
||||
type: 'formatura'
|
||||
}
|
||||
];
|
||||
|
||||
export const CalendarPage: React.FC = () => {
|
||||
const [selectedMonth, setSelectedMonth] = useState(new Date());
|
||||
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||
|
||||
const getStatusColor = (status: Event['status']) => {
|
||||
switch (status) {
|
||||
case 'confirmed':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'completed':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: Event['status']) => {
|
||||
switch (status) {
|
||||
case 'confirmed':
|
||||
return 'Confirmado';
|
||||
case 'pending':
|
||||
return 'Pendente';
|
||||
case 'completed':
|
||||
return 'Concluído';
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeColor = (type: Event['type']) => {
|
||||
switch (type) {
|
||||
case 'formatura':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'casamento':
|
||||
return 'bg-pink-100 text-pink-800';
|
||||
case 'evento':
|
||||
return 'bg-purple-100 text-purple-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeLabel = (type: Event['type']) => {
|
||||
switch (type) {
|
||||
case 'formatura':
|
||||
return 'Formatura';
|
||||
case 'casamento':
|
||||
return 'Casamento';
|
||||
case 'evento':
|
||||
return 'Evento';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString + 'T00:00:00');
|
||||
return date.toLocaleDateString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const nextMonth = () => {
|
||||
setSelectedMonth(new Date(selectedMonth.getFullYear(), selectedMonth.getMonth() + 1));
|
||||
};
|
||||
|
||||
const prevMonth = () => {
|
||||
setSelectedMonth(new Date(selectedMonth.getFullYear(), selectedMonth.getMonth() - 1));
|
||||
};
|
||||
|
||||
const currentMonthName = selectedMonth.toLocaleDateString('pt-BR', {
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
|
||||
// Generate calendar days
|
||||
const generateCalendarDays = () => {
|
||||
const year = selectedMonth.getFullYear();
|
||||
const month = selectedMonth.getMonth();
|
||||
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
|
||||
const firstDayOfWeek = firstDay.getDay();
|
||||
const daysInMonth = lastDay.getDate();
|
||||
|
||||
const days: (Date | null)[] = [];
|
||||
|
||||
// Add empty cells for days before month starts
|
||||
for (let i = 0; i < firstDayOfWeek; i++) {
|
||||
days.push(null);
|
||||
}
|
||||
|
||||
// Add all days of the month
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
days.push(new Date(year, month, day));
|
||||
}
|
||||
|
||||
return days;
|
||||
};
|
||||
|
||||
const getEventsForDate = (date: Date) => {
|
||||
return MOCK_EVENTS.filter(event => {
|
||||
const eventDate = new Date(event.date + 'T00:00:00');
|
||||
return eventDate.getDate() === date.getDate() &&
|
||||
eventDate.getMonth() === date.getMonth() &&
|
||||
eventDate.getFullYear() === date.getFullYear();
|
||||
});
|
||||
};
|
||||
|
||||
const isToday = (date: Date) => {
|
||||
const today = new Date();
|
||||
return date.getDate() === today.getDate() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getFullYear() === today.getFullYear();
|
||||
};
|
||||
|
||||
const calendarDays = generateCalendarDays();
|
||||
|
||||
// Filter events for selected month
|
||||
const monthEvents = MOCK_EVENTS.filter(event => {
|
||||
const eventDate = new Date(event.date + 'T00:00:00');
|
||||
return eventDate.getMonth() === selectedMonth.getMonth() &&
|
||||
eventDate.getFullYear() === selectedMonth.getFullYear();
|
||||
});
|
||||
|
||||
// Sort events by date
|
||||
const sortedEvents = [...MOCK_EVENTS].sort((a, b) =>
|
||||
new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pt-20 sm:pt-24 md:pt-32 pb-8 sm:pb-12">
|
||||
<div className="max-w-7xl mx-auto px-3 sm:px-4 md:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<h1 className="text-2xl sm:text-3xl font-serif font-bold text-brand-black mb-1 sm:mb-2">
|
||||
Minha Agenda
|
||||
</h1>
|
||||
<p className="text-sm sm:text-base text-gray-600">
|
||||
Gerencie seus eventos e compromissos fotográficos
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||
{/* Full Calendar Grid */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-3 sm:p-4">
|
||||
<div className="flex items-center justify-between mb-3 sm:mb-4">
|
||||
<button
|
||||
onClick={prevMonth}
|
||||
className="p-1 sm:p-1.5 hover:bg-gray-100 rounded-full transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
<h2 className="text-base sm:text-lg font-bold capitalize" style={{ color: '#B9CF33' }}>
|
||||
{currentMonthName}
|
||||
</h2>
|
||||
<button
|
||||
onClick={nextMonth}
|
||||
className="p-1 sm:p-1.5 hover:bg-gray-100 rounded-full transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Calendar Grid */}
|
||||
<div className="mb-3 sm:mb-4">
|
||||
{/* Week Days Header */}
|
||||
<div className="grid grid-cols-7 gap-0.5 sm:gap-1 mb-1">
|
||||
{['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb'].map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="text-center text-[10px] sm:text-xs font-semibold text-gray-600 py-1"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Calendar Days */}
|
||||
<div className="grid grid-cols-7 gap-0.5 sm:gap-1">
|
||||
{calendarDays.map((date, index) => {
|
||||
if (!date) {
|
||||
return (
|
||||
<div
|
||||
key={`empty-${index}`}
|
||||
className="h-10 sm:h-12 bg-gray-50 rounded"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const dayEvents = getEventsForDate(date);
|
||||
const hasEvents = dayEvents.length > 0;
|
||||
const today = isToday(date);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setSelectedDate(date)}
|
||||
className={`
|
||||
h-10 sm:h-12 rounded p-0.5 sm:p-1 transition-all duration-200
|
||||
${today ? 'bg-[#B9CF33] text-white font-bold' : 'hover:bg-gray-100 active:bg-gray-200'}
|
||||
${hasEvents && !today ? 'bg-blue-50 border border-[#B9CF33] sm:border-2' : 'bg-white border border-gray-200'}
|
||||
relative group
|
||||
`}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<span className={`text-[10px] sm:text-xs ${today ? 'text-white' : 'text-gray-700'}`}>
|
||||
{date.getDate()}
|
||||
</span>
|
||||
{hasEvents && (
|
||||
<div className="flex gap-0.5 mt-0.5">
|
||||
{dayEvents.slice(0, 3).map((event, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-0.5 h-0.5 sm:w-1 sm:h-1 rounded-full ${
|
||||
event.status === 'confirmed' ? 'bg-green-500' :
|
||||
event.status === 'pending' ? 'bg-yellow-500' :
|
||||
'bg-gray-400'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tooltip on hover - hide on mobile */}
|
||||
{hasEvents && (
|
||||
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 hidden sm:group-hover:block z-10">
|
||||
<div className="bg-gray-900 text-white text-xs rounded py-1 px-2 whitespace-nowrap">
|
||||
{dayEvents.length} evento{dayEvents.length > 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 sm:mt-4 pt-3 sm:pt-4 border-t border-gray-200">
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="text-gray-600 text-[10px] sm:text-xs">Eventos este mês:</span>
|
||||
<span className="font-bold text-sm sm:text-base" style={{ color: '#B9CF33' }}>{monthEvents.length}</span>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="text-gray-600 text-[10px] sm:text-xs">Total:</span>
|
||||
<span className="font-semibold text-sm sm:text-base">{MOCK_EVENTS.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend and Info Sidebar */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-3 sm:p-4 lg:sticky lg:top-24">
|
||||
<h3 className="text-sm font-bold mb-2 sm:mb-3" style={{ color: '#492E61' }}>Legenda</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-1 gap-2 mb-3 sm:mb-4">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className="w-3 h-3 rounded-full bg-green-500 flex-shrink-0"></div>
|
||||
<span>Confirmado</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-500 flex-shrink-0"></div>
|
||||
<span>Pendente</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className="w-3 h-3 rounded-full bg-gray-500 flex-shrink-0"></div>
|
||||
<span>Concluído</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className="w-3 h-3 rounded flex-shrink-0" style={{ backgroundColor: '#B9CF33' }}></div>
|
||||
<span>Hoje</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs col-span-2 sm:col-span-1">
|
||||
<div className="w-3 h-3 rounded bg-blue-50 border-2 flex-shrink-0" style={{ borderColor: '#B9CF33' }}></div>
|
||||
<span>Com eventos</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedDate && (
|
||||
<div className="pt-3 sm:pt-4 border-t border-gray-200">
|
||||
<h3 className="text-xs font-bold mb-2" style={{ color: '#492E61' }}>
|
||||
{selectedDate.toLocaleDateString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</h3>
|
||||
{getEventsForDate(selectedDate).length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{getEventsForDate(selectedDate).map(event => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="p-2 bg-gray-50 rounded cursor-pointer hover:bg-gray-100 active:bg-gray-200 transition-colors"
|
||||
onClick={() => setSelectedEvent(event)}
|
||||
>
|
||||
<div className="font-medium text-xs text-gray-900 mb-1 line-clamp-1">
|
||||
{event.title}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 flex items-center">
|
||||
<Clock size={10} className="inline mr-1 flex-shrink-0" />
|
||||
{event.time}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-500">Nenhum evento neste dia</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events List */}
|
||||
<div className="lg:col-span-3 mt-4 sm:mt-6">
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div className="p-4 sm:p-6 border-b border-gray-200">
|
||||
<h2 className="text-lg sm:text-xl font-bold" style={{ color: '#492E61' }}>Próximos Eventos</h2>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-gray-200">
|
||||
{sortedEvents.length === 0 ? (
|
||||
<div className="p-8 sm:p-12 text-center text-gray-500">
|
||||
<Calendar className="w-10 h-10 sm:w-12 sm:h-12 mx-auto mb-3 sm:mb-4 text-gray-300" />
|
||||
<p className="text-sm sm:text-base">Nenhum evento agendado</p>
|
||||
</div>
|
||||
) : (
|
||||
sortedEvents.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="p-4 sm:p-6 hover:bg-gray-50 active:bg-gray-100 transition-colors cursor-pointer"
|
||||
onClick={() => setSelectedEvent(event)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2 sm:mb-3 gap-3 sm:gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start sm:items-center gap-2 mb-2 flex-col sm:flex-row">
|
||||
<h3 className="text-base sm:text-lg font-semibold text-brand-black line-clamp-2">
|
||||
{event.title}
|
||||
</h3>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getTypeColor(event.type)} whitespace-nowrap`}>
|
||||
{getTypeLabel(event.type)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5 sm:gap-2">
|
||||
<div className="flex items-center text-xs sm:text-sm text-gray-600">
|
||||
<Calendar className="w-3 h-3 sm:w-4 sm:h-4 mr-1.5 sm:mr-2 flex-shrink-0" style={{ color: '#B9CF33' }} />
|
||||
<span className="truncate">{formatDate(event.date)}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-xs sm:text-sm text-gray-600">
|
||||
<Clock className="w-3 h-3 sm:w-4 sm:h-4 mr-1.5 sm:mr-2 flex-shrink-0" style={{ color: '#B9CF33' }} />
|
||||
<span>{event.time}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-xs sm:text-sm text-gray-600">
|
||||
<MapPin className="w-3 h-3 sm:w-4 sm:h-4 mr-1.5 sm:mr-2 flex-shrink-0" style={{ color: '#B9CF33' }} />
|
||||
<span className="truncate">{event.location}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-xs sm:text-sm text-gray-600">
|
||||
<User className="w-3 h-3 sm:w-4 sm:h-4 mr-1.5 sm:mr-2 flex-shrink-0" style={{ color: '#B9CF33' }} />
|
||||
<span className="truncate">{event.client}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-2 sm:px-3 py-1 rounded-full text-[10px] sm:text-xs font-medium whitespace-nowrap ${getStatusColor(event.status)} flex-shrink-0`}>
|
||||
{getStatusLabel(event.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event Detail Modal - Improved & Centered */}
|
||||
{selectedEvent && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fadeIn"
|
||||
onClick={() => setSelectedEvent(null)}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-2xl max-w-lg w-full shadow-2xl transform transition-all duration-300 animate-slideUp overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header with gradient */}
|
||||
<div className="relative p-6 pb-8 bg-gradient-to-br from-[#B9CF33] to-[#a5bd2e]">
|
||||
<button
|
||||
onClick={() => setSelectedEvent(null)}
|
||||
className="absolute top-4 right-4 w-8 h-8 flex items-center justify-center rounded-full bg-white/20 hover:bg-white/30 text-white transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<div className="text-white">
|
||||
<h2 className="text-xl sm:text-2xl font-bold mb-3 pr-8">
|
||||
{selectedEvent.title}
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="px-3 py-1 rounded-full text-xs font-medium bg-white/90 text-gray-800">
|
||||
{getTypeLabel(selectedEvent.type)}
|
||||
</span>
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(selectedEvent.status)}`}>
|
||||
{getStatusLabel(selectedEvent.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Date */}
|
||||
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-xl hover:bg-gray-100 transition-colors group">
|
||||
<div className="w-12 h-12 rounded-xl bg-[#B9CF33]/10 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||
<Calendar className="w-6 h-6" style={{ color: '#B9CF33' }} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-gray-500 mb-0.5">Data</p>
|
||||
<p className="font-semibold text-gray-900">{formatDate(selectedEvent.date)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-xl hover:bg-gray-100 transition-colors group">
|
||||
<div className="w-12 h-12 rounded-xl bg-[#B9CF33]/10 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||
<Clock className="w-6 h-6" style={{ color: '#B9CF33' }} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-gray-500 mb-0.5">Horário</p>
|
||||
<p className="font-semibold text-gray-900">{selectedEvent.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-xl hover:bg-gray-100 transition-colors group">
|
||||
<div className="w-12 h-12 rounded-xl bg-[#B9CF33]/10 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||
<MapPin className="w-6 h-6" style={{ color: '#B9CF33' }} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-gray-500 mb-0.5">Local</p>
|
||||
<p className="font-semibold text-gray-900">{selectedEvent.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Client */}
|
||||
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-xl hover:bg-gray-100 transition-colors group">
|
||||
<div className="w-12 h-12 rounded-xl bg-[#B9CF33]/10 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||
<User className="w-6 h-6" style={{ color: '#B9CF33' }} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-gray-500 mb-0.5">Cliente</p>
|
||||
<p className="font-semibold text-gray-900">{selectedEvent.client}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="p-6 pt-4 bg-gray-50 flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
onClick={() => setSelectedEvent(null)}
|
||||
className="flex-1 px-6 py-3 bg-white border-2 border-gray-200 text-gray-700 rounded-xl hover:bg-gray-50 hover:border-gray-300 active:scale-95 transition-all font-medium"
|
||||
>
|
||||
Fechar
|
||||
</button>
|
||||
<button
|
||||
className="flex-1 px-6 py-3 text-white rounded-xl transition-all font-medium shadow-lg hover:shadow-xl active:scale-95"
|
||||
style={{ backgroundColor: '#B9CF33' }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#a5bd2e'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#B9CF33'}
|
||||
>
|
||||
Ver Detalhes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.animate-slideUp {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
567
frontend/pages/Dashboard.tsx
Normal file
|
|
@ -0,0 +1,567 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { UserRole, EventData, EventStatus, EventType } from "../types";
|
||||
import { EventCard } from "../components/EventCard";
|
||||
import { EventForm } from "../components/EventForm";
|
||||
import { Button } from "../components/Button";
|
||||
import {
|
||||
PlusCircle,
|
||||
Search,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Edit,
|
||||
Users,
|
||||
Map,
|
||||
Building2,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { useData } from "../contexts/DataContext";
|
||||
import { STATUS_COLORS } from "../constants";
|
||||
|
||||
interface DashboardProps {
|
||||
initialView?: "list" | "create";
|
||||
}
|
||||
|
||||
export const Dashboard: React.FC<DashboardProps> = ({
|
||||
initialView = "list",
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const {
|
||||
events,
|
||||
getEventsByRole,
|
||||
addEvent,
|
||||
updateEventStatus,
|
||||
assignPhotographer,
|
||||
getInstitutionById,
|
||||
} = useData();
|
||||
const [view, setView] = useState<"list" | "create" | "edit" | "details">(
|
||||
initialView
|
||||
);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedEvent, setSelectedEvent] = useState<EventData | null>(null);
|
||||
const [activeFilter, setActiveFilter] = useState<string>("all");
|
||||
|
||||
// Reset view when initialView prop changes
|
||||
useEffect(() => {
|
||||
if (initialView) {
|
||||
setView(initialView);
|
||||
if (initialView === "create") setSelectedEvent(null);
|
||||
}
|
||||
}, [initialView]);
|
||||
|
||||
// Guard Clause for basic security
|
||||
if (!user)
|
||||
return <div className="p-10 text-center">Acesso Negado. Faça login.</div>;
|
||||
|
||||
const myEvents = getEventsByRole(user.id, user.role);
|
||||
|
||||
// Filter Logic
|
||||
const filteredEvents = myEvents.filter((e) => {
|
||||
const matchesSearch = e.name
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase());
|
||||
const matchesStatus =
|
||||
activeFilter === "all" ||
|
||||
(activeFilter === "pending" &&
|
||||
e.status === EventStatus.PENDING_APPROVAL) ||
|
||||
(activeFilter === "active" &&
|
||||
e.status !== EventStatus.ARCHIVED &&
|
||||
e.status !== EventStatus.PENDING_APPROVAL);
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
const handleSaveEvent = (data: any) => {
|
||||
const isClient = user.role === UserRole.EVENT_OWNER;
|
||||
|
||||
if (view === "edit" && selectedEvent) {
|
||||
const updatedEvent = { ...selectedEvent, ...data };
|
||||
console.log("Updated", updatedEvent);
|
||||
setSelectedEvent(updatedEvent);
|
||||
setView("details");
|
||||
} else {
|
||||
const initialStatus = isClient
|
||||
? EventStatus.PENDING_APPROVAL
|
||||
: EventStatus.PLANNING;
|
||||
const newEvent: EventData = {
|
||||
...data,
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
status: initialStatus,
|
||||
checklist: [],
|
||||
ownerId: isClient ? user.id : "unknown",
|
||||
photographerIds: [],
|
||||
};
|
||||
addEvent(newEvent);
|
||||
setView("list");
|
||||
}
|
||||
};
|
||||
|
||||
const handleApprove = (e: React.MouseEvent, eventId: string) => {
|
||||
e.stopPropagation();
|
||||
updateEventStatus(eventId, EventStatus.CONFIRMED);
|
||||
};
|
||||
|
||||
const handleOpenMaps = () => {
|
||||
if (!selectedEvent) return;
|
||||
if (selectedEvent.address.mapLink) {
|
||||
window.open(selectedEvent.address.mapLink, "_blank");
|
||||
return;
|
||||
}
|
||||
const { street, number, city, state } = selectedEvent.address;
|
||||
const query = encodeURIComponent(
|
||||
`${street}, ${number}, ${city} - ${state}`
|
||||
);
|
||||
window.open(
|
||||
`https://www.google.com/maps/search/?api=1&query=${query}`,
|
||||
"_blank"
|
||||
);
|
||||
};
|
||||
|
||||
const handleManageTeam = () => {
|
||||
if (!selectedEvent) return;
|
||||
const newId = window.prompt(
|
||||
"ID do Fotógrafo para adicionar (ex: photographer-1):",
|
||||
"photographer-1"
|
||||
);
|
||||
if (newId) {
|
||||
assignPhotographer(selectedEvent.id, newId);
|
||||
alert("Fotógrafo atribuído com sucesso!");
|
||||
const updated = events.find((e) => e.id === selectedEvent.id);
|
||||
if (updated) setSelectedEvent(updated);
|
||||
}
|
||||
};
|
||||
|
||||
// --- RENDERS PER ROLE ---
|
||||
|
||||
const renderRoleSpecificHeader = () => {
|
||||
if (user.role === UserRole.EVENT_OWNER) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-3xl font-serif font-bold text-brand-black">
|
||||
Meus Eventos
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
Acompanhe seus eventos ou solicite novos orçamentos.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (user.role === UserRole.PHOTOGRAPHER) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-3xl font-serif font-bold text-brand-black">
|
||||
Eventos Designados
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
Gerencie seus trabalhos e visualize detalhes.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-3xl font-serif font-bold text-brand-black">
|
||||
Gestão Geral
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
Controle total de eventos, aprovações e equipes.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderRoleSpecificActions = () => {
|
||||
if (user.role === UserRole.PHOTOGRAPHER) return null;
|
||||
|
||||
const label =
|
||||
user.role === UserRole.EVENT_OWNER
|
||||
? "Solicitar Novo Evento"
|
||||
: "Novo Evento";
|
||||
|
||||
return (
|
||||
<Button onClick={() => setView("create")} className="shadow-lg">
|
||||
<PlusCircle className="mr-2 h-5 w-5" /> {label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const renderAdminActions = (event: EventData) => {
|
||||
if (
|
||||
user.role !== UserRole.BUSINESS_OWNER &&
|
||||
user.role !== UserRole.SUPERADMIN
|
||||
)
|
||||
return null;
|
||||
|
||||
if (event.status === EventStatus.PENDING_APPROVAL) {
|
||||
return (
|
||||
<div className="absolute top-3 left-3 flex space-x-2 z-10">
|
||||
<button
|
||||
onClick={(e) => handleApprove(e, event.id)}
|
||||
className="bg-green-500 text-white px-3 py-1 rounded-sm text-xs font-bold shadow hover:bg-green-600 transition-colors flex items-center"
|
||||
>
|
||||
<CheckCircle size={12} className="mr-1" /> APROVAR
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// --- MAIN RENDER ---
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white pt-24 pb-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
{view === "list" && (
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between mb-8 gap-4 fade-in">
|
||||
{renderRoleSpecificHeader()}
|
||||
{renderRoleSpecificActions()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Switcher */}
|
||||
{view === "list" && (
|
||||
<div className="space-y-6 fade-in">
|
||||
{/* Filters Bar */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-center justify-between bg-gray-50 p-3 rounded-lg border border-gray-100">
|
||||
<div className="relative flex-1 w-full">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar evento..."
|
||||
className="w-full pl-10 pr-4 py-2 bg-white border border-gray-200 rounded-sm focus:outline-none focus:border-brand-gold text-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(user.role === UserRole.BUSINESS_OWNER ||
|
||||
user.role === UserRole.SUPERADMIN) && (
|
||||
<div className="flex space-x-2 bg-white p-1 rounded border border-gray-200">
|
||||
<button
|
||||
onClick={() => setActiveFilter("all")}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-sm ${
|
||||
activeFilter === "all"
|
||||
? "bg-brand-black text-white"
|
||||
: "text-gray-600 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
Todos
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveFilter("pending")}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-sm flex items-center ${
|
||||
activeFilter === "pending"
|
||||
? "bg-brand-gold text-white"
|
||||
: "text-gray-600 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
<Clock size={12} className="mr-1" /> Pendentes
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{filteredEvents.map((event) => (
|
||||
<div key={event.id} className="relative group">
|
||||
{renderAdminActions(event)}
|
||||
<EventCard
|
||||
event={event}
|
||||
onClick={() => {
|
||||
setSelectedEvent(event);
|
||||
setView("details");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredEvents.length === 0 && (
|
||||
<div className="text-center py-20 bg-gray-50 rounded-lg border border-dashed border-gray-200">
|
||||
<p className="text-gray-500 mb-4">
|
||||
Nenhum evento encontrado com os filtros atuais.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(view === "create" || view === "edit") && (
|
||||
<EventForm
|
||||
onCancel={() => setView(view === "edit" ? "details" : "list")}
|
||||
onSubmit={handleSaveEvent}
|
||||
initialData={view === "edit" ? selectedEvent : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === "details" && selectedEvent && (
|
||||
<div className="fade-in">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setView("list")}
|
||||
className="mb-4 pl-0"
|
||||
>
|
||||
← Voltar para lista
|
||||
</Button>
|
||||
|
||||
{/* Status Banner */}
|
||||
{selectedEvent.status === EventStatus.PENDING_APPROVAL &&
|
||||
user.role === UserRole.EVENT_OWNER && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 text-yellow-800 p-4 rounded-lg mb-6 flex items-start">
|
||||
<Clock className="mr-3 flex-shrink-0" />
|
||||
<div>
|
||||
<h4 className="font-bold">Solicitação em Análise</h4>
|
||||
<p className="text-sm mt-1">
|
||||
Seu evento foi enviado e está aguardando aprovação da
|
||||
equipe Photum.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white border rounded-lg overflow-hidden shadow-sm">
|
||||
<div className="h-64 w-full relative">
|
||||
<img
|
||||
src={selectedEvent.coverImage}
|
||||
className="w-full h-full object-cover"
|
||||
alt="Cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
||||
<h1 className="text-4xl font-serif text-white font-bold text-center px-4 drop-shadow-lg">
|
||||
{selectedEvent.name}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div className="col-span-2 space-y-8">
|
||||
{/* Actions Toolbar */}
|
||||
<div className="flex flex-wrap gap-3 border-b pb-4">
|
||||
{(user.role === UserRole.BUSINESS_OWNER ||
|
||||
user.role === UserRole.SUPERADMIN) && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setView("edit")}
|
||||
>
|
||||
<Edit size={16} className="mr-2" /> Editar Detalhes
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleManageTeam}>
|
||||
<Users size={16} className="mr-2" /> Gerenciar
|
||||
Equipe
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{user.role === UserRole.EVENT_OWNER &&
|
||||
selectedEvent.status !== EventStatus.ARCHIVED && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setView("edit")}
|
||||
>
|
||||
<Edit size={16} className="mr-2" /> Editar
|
||||
Informações
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Institution Information */}
|
||||
{selectedEvent.institutionId &&
|
||||
(() => {
|
||||
const institution = getInstitutionById(
|
||||
selectedEvent.institutionId
|
||||
);
|
||||
if (institution) {
|
||||
return (
|
||||
<section className="bg-gradient-to-br from-brand-gold/10 to-transparent border border-brand-gold/30 rounded-sm p-6">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="bg-brand-gold/20 p-3 rounded-full">
|
||||
<Building2
|
||||
className="text-brand-gold"
|
||||
size={24}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-bold text-brand-black mb-1">
|
||||
{institution.name}
|
||||
</h3>
|
||||
<p className="text-sm text-brand-gold uppercase tracking-wide font-medium mb-3">
|
||||
{institution.type}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-gray-500 text-xs uppercase tracking-wide">
|
||||
Contato
|
||||
</p>
|
||||
<p className="text-gray-700 font-medium">
|
||||
{institution.phone}
|
||||
</p>
|
||||
<p className="text-gray-600">
|
||||
{institution.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{institution.address && (
|
||||
<div>
|
||||
<p className="text-gray-500 text-xs uppercase tracking-wide">
|
||||
Endereço
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
{institution.address.street},{" "}
|
||||
{institution.address.number}
|
||||
</p>
|
||||
<p className="text-gray-600">
|
||||
{institution.address.city} -{" "}
|
||||
{institution.address.state}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{institution.description && (
|
||||
<p className="text-gray-600 text-sm mt-3 italic border-t border-brand-gold/20 pt-3">
|
||||
{institution.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
||||
<section>
|
||||
<h3 className="text-lg font-bold border-b pb-2 mb-4 text-brand-black">
|
||||
Sobre o Evento
|
||||
</h3>
|
||||
<p className="text-gray-600 leading-relaxed whitespace-pre-wrap">
|
||||
{selectedEvent.briefing || "Sem briefing detalhado."}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{selectedEvent.contacts.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-lg font-bold border-b pb-2 mb-4 text-brand-black">
|
||||
Contatos & Responsáveis
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{selectedEvent.contacts.map((c, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-gray-50 p-4 rounded-sm border border-gray-100"
|
||||
>
|
||||
<p className="font-bold text-sm">{c.name}</p>
|
||||
<p className="text-xs text-brand-gold uppercase tracking-wide">
|
||||
{c.role}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{c.phone}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-span-1 space-y-6">
|
||||
<div
|
||||
className={`p-6 rounded-sm border ${
|
||||
STATUS_COLORS[selectedEvent.status]
|
||||
} bg-opacity-10`}
|
||||
>
|
||||
<h4 className="font-bold uppercase tracking-widest text-xs mb-2 opacity-70">
|
||||
Status Atual
|
||||
</h4>
|
||||
<p className="text-xl font-serif font-bold">
|
||||
{selectedEvent.status}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border p-6 rounded-sm bg-gray-50">
|
||||
<h4 className="font-bold uppercase tracking-widest text-xs mb-4 text-gray-400">
|
||||
Localização
|
||||
</h4>
|
||||
<p className="font-medium text-lg">
|
||||
{selectedEvent.address.street},{" "}
|
||||
{selectedEvent.address.number}
|
||||
</p>
|
||||
<p className="text-gray-500 mb-4">
|
||||
{selectedEvent.address.city} -{" "}
|
||||
{selectedEvent.address.state}
|
||||
</p>
|
||||
|
||||
{selectedEvent.address.mapLink ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={handleOpenMaps}
|
||||
>
|
||||
<Map size={16} className="mr-2" /> Abrir no Google
|
||||
Maps
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full bg-white"
|
||||
onClick={handleOpenMaps}
|
||||
>
|
||||
<Map size={16} className="mr-2" /> Buscar no Maps
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(selectedEvent.photographerIds.length > 0 ||
|
||||
user.role === UserRole.BUSINESS_OWNER) && (
|
||||
<div className="border p-6 rounded-sm">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h4 className="font-bold uppercase tracking-widest text-xs text-gray-400">
|
||||
Equipe Designada
|
||||
</h4>
|
||||
{(user.role === UserRole.BUSINESS_OWNER ||
|
||||
user.role === UserRole.SUPERADMIN) && (
|
||||
<button
|
||||
onClick={handleManageTeam}
|
||||
className="text-brand-gold hover:text-brand-black"
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedEvent.photographerIds.length > 0 ? (
|
||||
<div className="flex -space-x-2">
|
||||
{selectedEvent.photographerIds.map((id, idx) => (
|
||||
<div
|
||||
key={id}
|
||||
className="w-10 h-10 rounded-full border-2 border-white bg-gray-300"
|
||||
style={{
|
||||
backgroundImage: `url(https://i.pravatar.cc/100?u=${id})`,
|
||||
backgroundSize: "cover",
|
||||
}}
|
||||
title={id}
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400 italic">
|
||||
Nenhum profissional atribuído.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
371
frontend/pages/Finance.tsx
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
import React, { useState } from 'react';
|
||||
import { DollarSign, TrendingUp, TrendingDown, Calendar, Download, Filter, CreditCard, CheckCircle, Clock, XCircle } from 'lucide-react';
|
||||
|
||||
interface Transaction {
|
||||
id: string;
|
||||
type: 'income' | 'expense';
|
||||
category: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
date: string;
|
||||
status: 'paid' | 'pending' | 'overdue';
|
||||
client?: string;
|
||||
event?: string;
|
||||
}
|
||||
|
||||
const MOCK_TRANSACTIONS: Transaction[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'income',
|
||||
category: 'Formatura',
|
||||
description: 'Pagamento Formatura Medicina UFPR',
|
||||
amount: 8500.00,
|
||||
date: '2025-12-01',
|
||||
status: 'paid',
|
||||
client: 'Ana Paula Silva',
|
||||
event: 'Formatura Medicina UFPR'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'income',
|
||||
category: 'Casamento',
|
||||
description: 'Sinal Casamento Maria & João',
|
||||
amount: 3000.00,
|
||||
date: '2025-12-05',
|
||||
status: 'paid',
|
||||
client: 'Maria Santos',
|
||||
event: 'Casamento Maria & João'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'expense',
|
||||
category: 'Equipamento',
|
||||
description: 'Manutenção Câmera Canon',
|
||||
amount: 450.00,
|
||||
date: '2025-12-03',
|
||||
status: 'paid'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'income',
|
||||
category: 'Formatura',
|
||||
description: 'Pagamento Formatura Direito PUC',
|
||||
amount: 7200.00,
|
||||
date: '2025-12-10',
|
||||
status: 'pending',
|
||||
client: 'Carlos Eduardo',
|
||||
event: 'Formatura Direito PUC'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
type: 'expense',
|
||||
category: 'Transporte',
|
||||
description: 'Combustível - Eventos Dezembro',
|
||||
amount: 320.00,
|
||||
date: '2025-12-08',
|
||||
status: 'paid'
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
type: 'income',
|
||||
category: 'Evento Corporativo',
|
||||
description: 'Tech Summit 2026',
|
||||
amount: 5500.00,
|
||||
date: '2025-12-15',
|
||||
status: 'pending',
|
||||
client: 'TechCorp Ltda',
|
||||
event: 'Tech Summit'
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
type: 'expense',
|
||||
category: 'Software',
|
||||
description: 'Assinatura Adobe Creative Cloud',
|
||||
amount: 180.00,
|
||||
date: '2025-12-01',
|
||||
status: 'paid'
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
type: 'income',
|
||||
category: 'Formatura',
|
||||
description: 'Saldo Final Formatura Engenharia',
|
||||
amount: 4500.00,
|
||||
date: '2025-11-20',
|
||||
status: 'overdue',
|
||||
client: 'Roberto Mendes',
|
||||
event: 'Formatura Engenharia UTFPR'
|
||||
}
|
||||
];
|
||||
|
||||
export const FinancePage: React.FC = () => {
|
||||
const [filterType, setFilterType] = useState<'all' | 'income' | 'expense'>('all');
|
||||
const [filterStatus, setFilterStatus] = useState<'all' | 'paid' | 'pending' | 'overdue'>('all');
|
||||
|
||||
const filteredTransactions = MOCK_TRANSACTIONS.filter(transaction => {
|
||||
const matchesType = filterType === 'all' || transaction.type === filterType;
|
||||
const matchesStatus = filterStatus === 'all' || transaction.status === filterStatus;
|
||||
return matchesType && matchesStatus;
|
||||
});
|
||||
|
||||
const totalIncome = MOCK_TRANSACTIONS
|
||||
.filter(t => t.type === 'income' && t.status === 'paid')
|
||||
.reduce((sum, t) => sum + t.amount, 0);
|
||||
|
||||
const totalExpense = MOCK_TRANSACTIONS
|
||||
.filter(t => t.type === 'expense' && t.status === 'paid')
|
||||
.reduce((sum, t) => sum + t.amount, 0);
|
||||
|
||||
const pendingIncome = MOCK_TRANSACTIONS
|
||||
.filter(t => t.type === 'income' && (t.status === 'pending' || t.status === 'overdue'))
|
||||
.reduce((sum, t) => sum + t.amount, 0);
|
||||
|
||||
const balance = totalIncome - totalExpense;
|
||||
|
||||
const getStatusIcon = (status: Transaction['status']) => {
|
||||
switch (status) {
|
||||
case 'paid':
|
||||
return <CheckCircle size={16} className="text-green-600" />;
|
||||
case 'pending':
|
||||
return <Clock size={16} className="text-yellow-600" />;
|
||||
case 'overdue':
|
||||
return <XCircle size={16} className="text-red-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: Transaction['status']) => {
|
||||
switch (status) {
|
||||
case 'paid':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'overdue':
|
||||
return 'bg-red-100 text-red-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: Transaction['status']) => {
|
||||
switch (status) {
|
||||
case 'paid':
|
||||
return 'Pago';
|
||||
case 'pending':
|
||||
return 'Pendente';
|
||||
case 'overdue':
|
||||
return 'Atrasado';
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat('pt-BR', {
|
||||
style: 'currency',
|
||||
currency: 'BRL'
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString + 'T00:00:00');
|
||||
return date.toLocaleDateString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pt-32 pb-12">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-serif font-bold text-brand-black mb-2">
|
||||
Financeiro
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
Acompanhe receitas, despesas e fluxo de caixa
|
||||
</p>
|
||||
</div>
|
||||
<button className="flex items-center gap-2 px-4 py-2 bg-brand-gold text-white rounded-md hover:bg-[#a5bd2e] transition-colors font-medium">
|
||||
<Download size={20} />
|
||||
Exportar Relatório
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm text-gray-600">Receitas</p>
|
||||
<TrendingUp className="text-green-600" size={24} />
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-green-600">{formatCurrency(totalIncome)}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Pagamentos recebidos</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm text-gray-600">Despesas</p>
|
||||
<TrendingDown className="text-red-600" size={24} />
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-red-600">{formatCurrency(totalExpense)}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Gastos do período</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm text-gray-600">Saldo</p>
|
||||
<DollarSign className="text-brand-gold" size={24} />
|
||||
</div>
|
||||
<p className={`text-3xl font-bold ${balance >= 0 ? 'text-brand-gold' : 'text-red-600'}`}>
|
||||
{formatCurrency(balance)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Receitas - Despesas</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm text-gray-600">A Receber</p>
|
||||
<Clock className="text-yellow-600" size={24} />
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-yellow-600">{formatCurrency(pendingIncome)}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Pagamentos pendentes</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setFilterType('all')}
|
||||
className={`px-4 py-2 rounded-md font-medium transition-colors ${filterType === 'all'
|
||||
? 'bg-brand-gold text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
Todas
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterType('income')}
|
||||
className={`px-4 py-2 rounded-md font-medium transition-colors ${filterType === 'income'
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
Receitas
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterType('expense')}
|
||||
className={`px-4 py-2 rounded-md font-medium transition-colors ${filterType === 'expense'
|
||||
? 'bg-red-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
Despesas
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setFilterStatus('all')}
|
||||
className={`px-4 py-2 rounded-md font-medium transition-colors ${filterStatus === 'all'
|
||||
? 'bg-brand-black text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
Todos Status
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterStatus('paid')}
|
||||
className={`px-4 py-2 rounded-md font-medium transition-colors ${filterStatus === 'paid'
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
Pagos
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterStatus('pending')}
|
||||
className={`px-4 py-2 rounded-md font-medium transition-colors ${filterStatus === 'pending'
|
||||
? 'bg-yellow-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
Pendentes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transactions List */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-semibold">Transações</h2>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-gray-200">
|
||||
{filteredTransactions.length === 0 ? (
|
||||
<div className="p-12 text-center text-gray-500">
|
||||
<CreditCard size={48} className="mx-auto mb-4 text-gray-300" />
|
||||
<p>Nenhuma transação encontrada</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredTransactions.map((transaction) => (
|
||||
<div
|
||||
key={transaction.id}
|
||||
className="p-6 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${transaction.type === 'income' ? 'bg-green-100' : 'bg-red-100'
|
||||
}`}>
|
||||
{transaction.type === 'income' ? (
|
||||
<TrendingUp size={20} className="text-green-600" />
|
||||
) : (
|
||||
<TrendingDown size={20} className="text-red-600" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-brand-black">
|
||||
{transaction.description}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-gray-500">{transaction.category}</span>
|
||||
{transaction.client && (
|
||||
<>
|
||||
<span className="text-xs text-gray-300">•</span>
|
||||
<span className="text-xs text-gray-500">{transaction.client}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className={`text-lg font-bold ${transaction.type === 'income' ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{transaction.type === 'income' ? '+' : '-'} {formatCurrency(transaction.amount)}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 text-xs text-gray-500 mt-1">
|
||||
<Calendar size={12} />
|
||||
{formatDate(transaction.date)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(transaction.status)}
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(transaction.status)}`}>
|
||||
{getStatusLabel(transaction.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
307
frontend/pages/Home.tsx
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Camera, Heart, Shield, Star, BookOpen } from 'lucide-react';
|
||||
|
||||
const HERO_IMAGES = [
|
||||
"/banner2.jpg",
|
||||
"/HOME_01.jpg"
|
||||
];
|
||||
|
||||
interface HomeProps {
|
||||
onEnter: () => void;
|
||||
}
|
||||
|
||||
// Hook para detectar quando elemento está visível
|
||||
const useIntersectionObserver = (options = {}) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true);
|
||||
}
|
||||
}, { threshold: 0.1, ...options });
|
||||
|
||||
if (ref.current) {
|
||||
observer.observe(ref.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (ref.current) {
|
||||
observer.unobserve(ref.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return [ref, isVisible] as const;
|
||||
};
|
||||
|
||||
export const Home: React.FC<HomeProps> = ({ onEnter }) => {
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
const [albumsRef, albumsVisible] = useIntersectionObserver();
|
||||
const [ctaRef, ctaVisible] = useIntersectionObserver();
|
||||
const [testimonialsRef, testimonialsVisible] = useIntersectionObserver();
|
||||
const [contactRef, contactVisible] = useIntersectionObserver();
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setCurrentSlide((prev) => (prev + 1) % HERO_IMAGES.length);
|
||||
}, 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Hero Section */}
|
||||
<div className="relative h-[50vh] sm:h-[60vh] md:h-[70vh] w-full overflow-hidden">
|
||||
{HERO_IMAGES.map((img, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`absolute inset-0 transition-opacity duration-1000 ease-in-out ${idx === currentSlide ? 'opacity-100' : 'opacity-0'}`}
|
||||
>
|
||||
<img src={img} alt="Hero" className="w-full h-full object-cover scale-110" />
|
||||
<div className="absolute inset-0 bg-black/40"></div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Text - Only visible on first slide */}
|
||||
<div className={`absolute inset-0 flex items-center justify-center text-center px-4 transition-opacity duration-500 ${currentSlide === 0 ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}>
|
||||
<div className="max-w-4xl space-y-4 sm:space-y-6 slide-up">
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-serif text-white leading-tight">
|
||||
Eternizando Momentos <br />
|
||||
<span className="text-brand-gold italic">Únicos</span>
|
||||
</h1>
|
||||
<p className="text-base sm:text-lg md:text-xl text-gray-200 font-light max-w-2xl mx-auto tracking-wide">
|
||||
Gestão completa para eventos inesquecíveis. Do planejamento à entrega do álbum perfeito.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Carousel Dots */}
|
||||
<div className="absolute bottom-4 sm:bottom-10 left-0 right-0 flex justify-center space-x-2 sm:space-x-3">
|
||||
{HERO_IMAGES.map((_, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
className={`w-2 h-2 rounded-full transition-all ${idx === currentSlide ? 'bg-brand-gold w-6 sm:w-8' : 'bg-white/50'}`}
|
||||
onClick={() => setCurrentSlide(idx)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Albums Section */}
|
||||
<section ref={albumsRef} className="py-12 sm:py-16 md:py-24 relative overflow-hidden" style={{ backgroundColor: '#ffffff' }}>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col lg:flex-row items-center gap-8 sm:gap-12 lg:gap-16">
|
||||
{/* Left side - Icon and Text */}
|
||||
<div className="lg:w-1/2 text-center lg:text-left w-full">
|
||||
<div className={`inline-flex items-center justify-center w-24 h-24 sm:w-32 sm:h-32 md:w-36 md:h-36 mb-6 sm:mb-8 transform transition-all duration-700 ease-out ${albumsVisible ? 'opacity-100 scale-100 rotate-0' : 'opacity-0 scale-75 -rotate-12'} hover:scale-110 hover:rotate-3`}>
|
||||
<img src="/HOME_17.png" alt="Álbuns" className="w-full h-full object-contain drop-shadow-2xl" />
|
||||
</div>
|
||||
|
||||
<h2 className={`text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold mb-4 sm:mb-6 md:mb-8 leading-tight transition-all duration-700 ease-out delay-100 ${albumsVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10'}`} style={{color: '#B9CF33'}}>
|
||||
ÁLBUNS<br/>PERSONALIZADOS
|
||||
</h2>
|
||||
|
||||
<div className={`space-y-2 sm:space-y-3 text-gray-700 transition-all duration-700 ease-out delay-200 ${albumsVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10'}`}>
|
||||
<p className="text-sm sm:text-base md:text-lg leading-relaxed">
|
||||
Escolha a cor, tamanho, tecido, acabamento, modelo,
|
||||
</p>
|
||||
<p className="text-sm sm:text-base md:text-lg leading-relaxed">
|
||||
laminação, tipo de impressão e muito mais!
|
||||
</p>
|
||||
<p className="text-base sm:text-lg md:text-xl font-semibold text-brand-black mt-3 sm:mt-4">
|
||||
Tenha seu álbum exclusivo e de acordo com o seu gosto.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - CTA */}
|
||||
<div ref={ctaRef} className={`w-full lg:w-2/8 flex flex-col items-center lg:items-start justify-center lg:ml-40 mt-8 lg:mt-0 transition-all duration-700 ease-out delay-300 ${ctaVisible ? 'opacity-100 translate-x-0 scale-100' : 'opacity-0 translate-x-20 scale-90'}`}>
|
||||
<div className="bg-white rounded-lg shadow-2xl p-6 sm:p-8 md:p-10 border-t-4 transform transition-all duration-300 hover:shadow-xl hover:-translate-y-2 w-full max-w-sm" style={{ borderColor: '#B9CF33' }}>
|
||||
<div className={`flex justify-center mb-4 sm:mb-6 transition-all duration-700 ease-out delay-400 ${ctaVisible ? 'opacity-100 scale-100' : 'opacity-0 scale-80'}`}>
|
||||
<div className="w-38 h-38 sm:w-38 sm:h-38 md:w-50 md:h-50 transform transition-transform duration-300 hover:scale-110">
|
||||
<img src="/logo.png" alt="Cadastrar" className="w-full h-full object-contain drop-shadow-lg" />
|
||||
</div>
|
||||
</div>
|
||||
<p className={`text-gray-700 text-base sm:text-lg md:text-xl mb-4 sm:mb-5 text-center font-medium leading-relaxed transition-all duration-700 ease-out delay-500 ${ctaVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-5'}`}>
|
||||
Faça parte da Photum e cadastre sua formatura.
|
||||
</p>
|
||||
<button
|
||||
onClick={onEnter}
|
||||
className={`w-full px-6 sm:px-8 md:px-10 py-3 sm:py-4 md:py-5 text-white font-bold text-base sm:text-lg rounded-md transition-all duration-700 ease-out delay-600 transform hover:scale-105 hover:shadow-2xl active:scale-95 ${ctaVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-5'}`}
|
||||
style={{ backgroundColor: '#B9CF33' }}
|
||||
>
|
||||
Cadastrar Formatura
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative elements */}
|
||||
<div className="hidden sm:block absolute top-10 right-10 w-32 h-32 rounded-full opacity-10" style={{ backgroundColor: '#B9CF33' }}></div>
|
||||
<div className="hidden sm:block absolute bottom-10 left-10 w-24 h-24 rounded-full opacity-10" style={{ backgroundColor: '#C2388B' }}></div>
|
||||
</section>
|
||||
|
||||
{/* Contact Section */}
|
||||
<section ref={contactRef} className="py-8 sm:py-12 md:py-16 relative overflow-hidden" style={{ backgroundColor: '#492E61' }}>
|
||||
{/* Decorative gradient circles */}
|
||||
<div className="hidden md:block absolute -top-24 -right-24 w-96 h-96 rounded-full opacity-10 blur-3xl" style={{ backgroundColor: '#B9CF33' }}></div>
|
||||
<div className="hidden md:block absolute -bottom-24 -left-24 w-96 h-96 rounded-full opacity-10 blur-3xl" style={{ backgroundColor: '#C2388B' }}></div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 md:px-8 lg:px-12 xl:px-20 relative z-10">
|
||||
<div className={`text-center mb-8 sm:mb-10 md:mb-12 transition-all duration-700 ${contactVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-10'}`}>
|
||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#B9CF32] mb-2 sm:mb-3 tracking-wide">ENTRE EM CONTATO</h2>
|
||||
<p className="text-sm sm:text-base text-white/90">Envie sua mensagem, ligue ou faça uma visita em nossa empresa!</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6 items-start max-w-7xl mx-auto">
|
||||
{/* Left side - Location Info */}
|
||||
<div className={`space-y-2.5 sm:space-y-3 text-white transition-all duration-700 delay-200 ${contactVisible ? 'opacity-100 translate-x-0' : 'opacity-0 -translate-x-20'}`}>
|
||||
<div className="group">
|
||||
<div className="flex items-start gap-2 p-2 sm:p-2.5 rounded-lg transition-all duration-300 hover:bg-white/10 hover:shadow-lg cursor-pointer border border-white/5 hover:border-white/20">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-white/20 flex items-center justify-center transition-transform duration-300 group-hover:scale-110">
|
||||
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-bold mb-0.5 text-sm">Rua Bom Recreio, 305</p>
|
||||
<p className="text-white/80 text-xs">Jd. Boer II • Americana SP</p>
|
||||
<p className="text-white/80 text-xs mb-1">CEP 13477-720</p>
|
||||
<a
|
||||
href="https://www.google.com/maps/place/Photum+Formaturas/@-22.7442887,-47.290221,21z/data=!4m12!1m5!3m4!2zMjLCsDQ0JzM5LjMiUyA0N8KwMTcnMjQuOCJX!8m2!3d-22.744247!4d-47.290221!3m5!1s0x94c89755cd9e70a9:0x15496eb4ec405483!8m2!3d-22.7442757!4d-47.2902662!16s%2Fg%2F11g6my_mm1?hl=pt&entry=ttu&g_ep=EgoyMDI1MTEyMy4xIKXMDSoASAFQAw%3D%3D"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs underline hover:text-white transition-colors inline-flex items-center gap-1 font-medium"
|
||||
>
|
||||
Ver no mapa →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mini Map */}
|
||||
<div className="rounded-lg overflow-hidden shadow-lg border border-white/10 h-32 sm:h-36">
|
||||
<iframe
|
||||
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d235.28736842715582!2d-47.29022099999999!3d-22.7442887!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x94c89755cd9e70a9%3A0x15496eb4ec405483!2sPhotum%20Formaturas!5e0!3m2!1spt-BR!2sbr!4v1701234567890!5m2!1spt-BR!2sbr"
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ border: 0 }}
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
<div className="group">
|
||||
<div className="flex items-center gap-2 p-2 sm:p-2.5 rounded-lg transition-all duration-300 hover:bg-white/10 hover:shadow-lg cursor-pointer border border-white/5 hover:border-white/20">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-white/20 flex items-center justify-center transition-transform duration-300 group-hover:scale-110">
|
||||
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M20 15.5c-1.25 0-2.45-.2-3.57-.57-.35-.11-.74-.03-1.02.24l-2.2 2.2c-2.83-1.44-5.15-3.75-6.59-6.59l2.2-2.21c.28-.26.36-.65.25-1C8.7 6.45 8.5 5.25 8.5 4c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1 0 9.39 7.61 17 17 17 .55 0 1-.45 1-1v-3.5c0-.55-.45-1-1-1zM19 12h2c0-4.97-4.03-9-9-9v2c3.87 0 7 3.13 7 7zm-4 0h2c0-2.76-2.24-5-5-5v2c1.66 0 3 1.34 3 3z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-sm">(19) 3405 5024</p>
|
||||
<p className="font-semibold text-sm">(19) 3621 4621</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="group">
|
||||
<div className="flex items-center gap-2 p-2 sm:p-2.5 rounded-lg transition-all duration-300 hover:bg-white/10 hover:shadow-lg cursor-pointer border border-white/5 hover:border-white/20">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-white/20 flex items-center justify-center transition-transform duration-300 group-hover:scale-110">
|
||||
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<a href="mailto:contato@photum.com.br" className="font-semibold hover:text-white transition-colors text-sm break-all">
|
||||
contato@photum.com.br
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center - Contact Form */}
|
||||
<div className={`space-y-2.5 bg-gradient-to-br from-white/10 to-white/5 p-4 sm:p-5 rounded-xl backdrop-blur-md border border-white/10 shadow-2xl transition-all duration-700 delay-250 ${contactVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10'}`}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nome"
|
||||
className="w-full px-3 py-2 text-sm bg-white/5 border-b-2 border-white/20 text-white placeholder-white/50 focus:border-white focus:bg-white/10 focus:outline-none transition-all duration-300 rounded-t-lg"
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="E-mail"
|
||||
className="w-full px-3 py-2 text-sm bg-white/5 border-b-2 border-white/20 text-white placeholder-white/50 focus:border-white focus:bg-white/10 focus:outline-none transition-all duration-300 rounded-t-lg"
|
||||
/>
|
||||
<input
|
||||
type="tel"
|
||||
placeholder="Telefone"
|
||||
className="w-full px-3 py-2 text-sm bg-white/5 border-b-2 border-white/20 text-white placeholder-white/50 focus:border-white focus:bg-white/10 focus:outline-none transition-all duration-300 rounded-t-lg"
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Mensagem"
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm bg-white/5 border-b-2 border-white/20 text-white placeholder-white/50 focus:border-white focus:bg-white/10 focus:outline-none transition-all duration-300 resize-none rounded-t-lg"
|
||||
></textarea>
|
||||
<button
|
||||
className="w-full px-6 py-2.5 text-sm text-white font-bold rounded-lg hover:opacity-90 transition-all duration-300 transform hover:scale-105 hover:shadow-xl active:scale-95 relative overflow-hidden group flex items-center justify-center gap-2"
|
||||
style={{ backgroundColor: '#B9CF32' }}
|
||||
>
|
||||
<span className="relative z-10">Enviar</span>
|
||||
<svg className="w-4 h-4 relative z-10 rotate-45" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700"></div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right side - WhatsApp Card */}
|
||||
<div className={`transition-all duration-700 delay-300 ${contactVisible ? 'opacity-100 translate-x-0' : 'opacity-0 translate-x-20'}`}>
|
||||
<div className="bg-gradient-to-br from-[#25D366]/20 to-[#128C7E]/20 p-5 sm:p-6 rounded-xl backdrop-blur-md border border-[#25D366]/30 shadow-2xl hover:shadow-[#25D366]/20 hover:-translate-y-1 transition-all duration-300">
|
||||
<div className="flex flex-col items-center text-center space-y-3">
|
||||
{/* WhatsApp Icon */}
|
||||
<div className="w-16 h-16 sm:w-20 sm:h-20 rounded-full bg-[#25D366] flex items-center justify-center shadow-lg transform hover:scale-110 transition-transform duration-300">
|
||||
<svg className="w-10 h-10 sm:w-12 sm:h-12 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg sm:text-xl font-bold text-white">Tire suas dúvidas</h3>
|
||||
<p className="text-xs sm:text-sm text-white/90 leading-snug">
|
||||
Faça orçamento direto no nosso WhatsApp
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* WhatsApp Button */}
|
||||
<a
|
||||
href="https://wa.me/5519999999999"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full bg-[#25D366] hover:bg-[#128C7E] text-white font-bold py-2.5 px-4 rounded-lg transition-all duration-300 transform hover:scale-105 hover:shadow-xl active:scale-95 flex items-center justify-center gap-2 group text-sm"
|
||||
>
|
||||
<svg className="w-5 h-5 transform group-hover:rotate-12 transition-transform duration-300" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
|
||||
</svg>
|
||||
<span>Falar no WhatsApp</span>
|
||||
</a>
|
||||
|
||||
{/* Additional Info */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-white/80">
|
||||
<div className="w-2 h-2 bg-[#25D366] rounded-full animate-pulse"></div>
|
||||
<span>Resposta rápida garantida</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
206
frontend/pages/Inspiration.tsx
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import React, { useState } from "react";
|
||||
import { Heart, Search, Filter } from "lucide-react";
|
||||
import { Construction } from "lucide-react";
|
||||
|
||||
const MOCK_GALLERIES = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Formatura Medicina UNICAMP 2024",
|
||||
category: "Medicina",
|
||||
images: [
|
||||
"https://images.unsplash.com/photo-1523050854058-8df90110c9f1?w=800",
|
||||
"https://images.unsplash.com/photo-1541339907198-e08756dedf3f?w=800",
|
||||
"https://images.unsplash.com/photo-1523240795612-9a054b0db644?w=800",
|
||||
],
|
||||
likes: 234,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Engenharia Civil - USP 2024",
|
||||
category: "Engenharia",
|
||||
images: [
|
||||
"https://images.unsplash.com/photo-1513542789411-b6a5d4f31634?w=800",
|
||||
"https://images.unsplash.com/photo-1511632765486-a01980e01a18?w=800",
|
||||
"https://images.unsplash.com/photo-1523050854058-8df90110c9f1?w=800",
|
||||
],
|
||||
likes: 189,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Direito PUC 2023",
|
||||
category: "Direito",
|
||||
images: [
|
||||
"https://images.unsplash.com/photo-1519389950473-47ba0277781c?w=800",
|
||||
"https://images.unsplash.com/photo-1521737711867-e3b97375f902?w=800",
|
||||
"https://images.unsplash.com/photo-1522202176988-66273c2fd55f?w=800",
|
||||
],
|
||||
likes: 312,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Arquitetura UNESP 2024",
|
||||
category: "Arquitetura",
|
||||
images: [
|
||||
"https://images.unsplash.com/photo-1528605248644-14dd04022da1?w=800",
|
||||
"https://images.unsplash.com/photo-1523050854058-8df90110c9f1?w=800",
|
||||
"https://images.unsplash.com/photo-1519337265831-281ec6cc8514?w=800",
|
||||
],
|
||||
likes: 278,
|
||||
},
|
||||
];
|
||||
|
||||
const CATEGORIES = [
|
||||
"Todas",
|
||||
"Medicina",
|
||||
"Engenharia",
|
||||
"Direito",
|
||||
"Arquitetura",
|
||||
"Administração",
|
||||
];
|
||||
|
||||
export const InspirationPage: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState("Todas");
|
||||
|
||||
const filteredGalleries = MOCK_GALLERIES.filter((gallery) => {
|
||||
const matchesSearch = gallery.title
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase());
|
||||
const matchesCategory =
|
||||
selectedCategory === "Todas" || gallery.category === selectedCategory;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 pt-24 pb-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12 fade-in">
|
||||
<h1 className="text-4xl sm:text-5xl font-serif font-bold text-brand-black mb-4">
|
||||
Galeria de Inspiração
|
||||
</h1>
|
||||
<p className="text-gray-600 text-lg max-w-2xl mx-auto">
|
||||
Explore álbuns de formaturas anteriores e inspire-se para criar o
|
||||
seu evento perfeito
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search and Filter */}
|
||||
<div className="mb-8 space-y-4">
|
||||
{/* Search Bar */}
|
||||
<div className="relative max-w-2xl mx-auto">
|
||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-gray-400 h-5 w-5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por curso, universidade..."
|
||||
className="w-full pl-12 pr-4 py-3 bg-white border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-gold focus:border-transparent text-sm shadow-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{CATEGORIES.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium transition-all ${
|
||||
selectedCategory === category
|
||||
? "bg-brand-gold text-white shadow-lg scale-105"
|
||||
: "bg-white text-gray-700 hover:bg-gray-50 border border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gallery Grid */}
|
||||
{filteredGalleries.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredGalleries.map((gallery) => (
|
||||
<div
|
||||
key={gallery.id}
|
||||
className="bg-white rounded-xl overflow-hidden shadow-md hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2 group"
|
||||
>
|
||||
{/* Main Image */}
|
||||
<div className="relative h-64 overflow-hidden">
|
||||
<img
|
||||
src={gallery.images[0]}
|
||||
alt={gallery.title}
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
|
||||
{/* Category Badge */}
|
||||
<div className="absolute top-3 left-3 bg-white/95 backdrop-blur-sm px-3 py-1 rounded-full">
|
||||
<span className="text-xs font-semibold text-brand-gold">
|
||||
{gallery.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-5">
|
||||
<h3 className="text-lg font-bold text-brand-black mb-2 line-clamp-1">
|
||||
{gallery.title}
|
||||
</h3>
|
||||
|
||||
{/* Thumbnail Preview */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
{gallery.images.slice(1, 3).map((img, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="w-16 h-16 rounded-lg overflow-hidden border border-gray-200"
|
||||
>
|
||||
<img
|
||||
src={img}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="w-16 h-16 rounded-lg bg-gray-100 flex items-center justify-center text-xs text-gray-500 font-medium border border-gray-200">
|
||||
+12
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-gray-100">
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<Heart size={16} className="text-red-400" />
|
||||
<span className="text-sm font-medium">
|
||||
{gallery.likes} curtidas
|
||||
</span>
|
||||
</div>
|
||||
<button className="text-sm text-brand-gold font-semibold hover:underline">
|
||||
Ver álbum →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-gray-500 text-lg">Nenhuma galeria encontrada</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Coming Soon Banner */}
|
||||
<div className="mt-16 bg-gradient-to-r from-brand-purple to-brand-gold/20 rounded-2xl p-8 text-center">
|
||||
<Construction className="mx-auto text-white mb-4" size={48} />
|
||||
<h2 className="text-2xl font-bold text-white mb-2">
|
||||
Em Breve: Mais Funcionalidades
|
||||
</h2>
|
||||
<p className="text-white/90 max-w-2xl mx-auto">
|
||||
Estamos trabalhando para trazer mais galerias, filtros avançados e a
|
||||
possibilidade de salvar seus favoritos!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
269
frontend/pages/LGPD.tsx
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
interface LGPDProps {
|
||||
onNavigate: (page: string) => void;
|
||||
}
|
||||
|
||||
interface Section {
|
||||
id: number;
|
||||
title: string;
|
||||
icon: string;
|
||||
content: React.ReactNode;
|
||||
}
|
||||
|
||||
export const LGPD: React.FC<LGPDProps> = ({ onNavigate }) => {
|
||||
const [openSection, setOpenSection] = useState<number | null>(null);
|
||||
|
||||
const sections: Section[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Compromisso com a LGPD',
|
||||
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>',
|
||||
content: (
|
||||
<p>
|
||||
A Photum Formaturas está comprometida em proteger seus dados pessoais e cumprir todas as
|
||||
disposições da Lei Geral de Proteção de Dados (Lei nº 13.709/2018). Esta página explica
|
||||
como tratamos seus dados em conformidade com a LGPD.
|
||||
</p>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Princípios da LGPD',
|
||||
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" /></svg>',
|
||||
content: (
|
||||
<>
|
||||
<p className="mb-4">Nosso tratamento de dados segue os seguintes princípios:</p>
|
||||
<ul className="list-none space-y-2">
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> <strong>Finalidade:</strong> Tratamento para propósitos específicos e legítimos</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> <strong>Adequação:</strong> Compatível com as finalidades informadas</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> <strong>Necessidade:</strong> Limitado ao mínimo necessário</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> <strong>Transparência:</strong> Informações claras e acessíveis</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> <strong>Segurança:</strong> Medidas técnicas e administrativas adequadas</li>
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Seus Direitos como Titular',
|
||||
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" /></svg>',
|
||||
content: (
|
||||
<>
|
||||
<p className="mb-4">A LGPD garante diversos direitos sobre seus dados pessoais:</p>
|
||||
<ul className="list-none space-y-2">
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Confirmação da existência de tratamento</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Acesso aos dados</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Correção de dados incompletos ou desatualizados</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Anonimização, bloqueio ou eliminação de dados desnecessários</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Portabilidade dos dados a outro fornecedor</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Eliminação dos dados tratados com seu consentimento</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Informação sobre o compartilhamento de dados</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Revogação do consentimento</li>
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Bases Legais para Tratamento',
|
||||
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" /></svg>',
|
||||
content: (
|
||||
<>
|
||||
<p className="mb-4">Tratamos seus dados com base nas seguintes hipóteses legais:</p>
|
||||
<ul className="list-none space-y-2">
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Consentimento do titular</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Cumprimento de obrigação legal ou regulatória</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Execução de contrato ou procedimentos preliminares</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Exercício regular de direitos</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Legítimos interesses do controlador</li>
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'Compartilhamento de Dados',
|
||||
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg>',
|
||||
content: (
|
||||
<>
|
||||
<p className="mb-4">Seus dados podem ser compartilhados com:</p>
|
||||
<ul className="list-none space-y-2">
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Prestadores de serviços de pagamento</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Provedores de infraestrutura tecnológica</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Autoridades governamentais (quando exigido por lei)</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Parceiros comerciais (com seu consentimento)</li>
|
||||
</ul>
|
||||
<p className="mt-4">
|
||||
Todos os terceiros são obrigados a manter a confidencialidade e segurança de seus dados.
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: 'Segurança dos Dados',
|
||||
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>',
|
||||
content: (
|
||||
<>
|
||||
<p className="mb-4">Implementamos medidas de segurança técnicas e organizacionais:</p>
|
||||
<ul className="list-none space-y-2">
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Criptografia de dados sensíveis</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Controles de acesso rigorosos</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Monitoramento contínuo de segurança</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Treinamento regular da equipe</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Plano de resposta a incidentes</li>
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: 'Retenção de Dados',
|
||||
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>',
|
||||
content: (
|
||||
<p>
|
||||
Mantemos seus dados pessoais apenas pelo tempo necessário para cumprir as finalidades para
|
||||
as quais foram coletados, incluindo requisitos legais, contratuais, de resolução de disputas
|
||||
e aplicação de nossos acordos. Após esse período, os dados são eliminados ou anonimizados de
|
||||
forma segura.
|
||||
</p>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
title: 'Encarregado de Proteção de Dados (DPO)',
|
||||
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>',
|
||||
content: (
|
||||
<>
|
||||
<p className="mb-4">
|
||||
Nosso Encarregado de Proteção de Dados está disponível para esclarecer dúvidas e receber
|
||||
solicitações relacionadas aos seus direitos:
|
||||
</p>
|
||||
<div className="bg-white p-4 rounded-lg border-l-4 mt-4" style={{borderColor: '#B9CF33'}}>
|
||||
<p className="font-semibold text-gray-800 mb-2">Contato do DPO:</p>
|
||||
<p className="text-gray-700">
|
||||
<a href="mailto:lgpd@photum.com.br" className="font-semibold hover:opacity-80 transition-opacity" style={{color: '#B9CF33'}}>
|
||||
lgpd@photum.com.br
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
title: 'Autoridade Nacional de Proteção de Dados (ANPD)',
|
||||
icon: '<svg className="w-12 h-12" fill="none" stroke="#492E61" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>',
|
||||
content: (
|
||||
<>
|
||||
<p className="mb-4">
|
||||
Você tem o direito de apresentar reclamação à Autoridade Nacional de Proteção de Dados:
|
||||
</p>
|
||||
<div className="bg-white p-4 rounded-lg border-l-4" style={{borderColor: '#492E61'}}>
|
||||
<p className="font-semibold text-gray-800 mb-2">ANPD:</p>
|
||||
<p className="text-gray-700">
|
||||
Website:{' '}
|
||||
<a href="https://www.gov.br/anpd" target="_blank" rel="noopener noreferrer" className="hover:opacity-80 transition-opacity" style={{color: '#492E61'}}>
|
||||
www.gov.br/anpd
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-white py-12 sm:py-20 px-4 sm:px-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<button
|
||||
onClick={() => onNavigate('home')}
|
||||
className="mb-8 flex items-center gap-2 text-gray-600 hover:text-[#492E61] transition-all duration-300 group"
|
||||
>
|
||||
<svg className="w-5 h-5 transform group-hover:-translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Voltar para Home
|
||||
</button>
|
||||
|
||||
<div className="text-center mb-12 fade-in">
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold mb-4" style={{ color: '#B9CF33' }}>
|
||||
Lei Geral de Proteção de Dados
|
||||
</h1>
|
||||
<p className="text-gray-600 text-lg">Transparência e segurança no tratamento dos seus dados</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:gap-6">
|
||||
{sections.map((section, index) => (
|
||||
<div
|
||||
key={section.id}
|
||||
className="bg-white rounded-2xl shadow-lg overflow-hidden transform transition-all duration-500 hover:shadow-2xl"
|
||||
style={{
|
||||
animation: `fadeInUp 0.6s ease-out ${index * 0.1}s both`
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setOpenSection(openSection === section.id ? null : section.id)}
|
||||
className="w-full px-6 sm:px-8 py-6 flex items-center justify-between text-left bg-gradient-to-r from-white to-gray-50 hover:from-gray-50 hover:to-gray-100 transition-all duration-300"
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="transform transition-transform duration-300 hover:scale-110" dangerouslySetInnerHTML={{ __html: section.icon }} />
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-gray-800">
|
||||
{section.title}
|
||||
</h2>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-6 h-6 transition-transform duration-300 ${
|
||||
openSection === section.id ? 'rotate-180' : ''
|
||||
}`}
|
||||
style={{ color: '#492E61' }}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-500 ${
|
||||
openSection === section.id ? 'max-h-[1000px] opacity-100' : 'max-h-0 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="px-6 sm:px-8 py-6 bg-gradient-to-br from-gray-50 to-white border-t border-gray-100">
|
||||
<div className="text-gray-700 leading-relaxed">
|
||||
{section.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-12 text-center p-6 bg-white rounded-2xl shadow-lg">
|
||||
<p className="text-gray-600">
|
||||
<span className="font-semibold" style={{ color: '#492E61' }}>Última atualização:</span> Janeiro de 2025
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeInUp 0.8s ease-out;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
160
frontend/pages/Login.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Button } from '../components/Button';
|
||||
import { Input } from '../components/Input';
|
||||
import { UserRole } from '../types';
|
||||
|
||||
interface LoginProps {
|
||||
onNavigate?: (page: string) => void;
|
||||
}
|
||||
|
||||
export const Login: React.FC<LoginProps> = ({ onNavigate }) => {
|
||||
const { login, availableUsers } = useAuth();
|
||||
const [email, setEmail] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
const success = await login(email);
|
||||
if (!success) {
|
||||
setError('Usuário não encontrado. Tente um dos e-mails de demonstração.');
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const fillCredentials = (userEmail: string) => {
|
||||
setEmail(userEmail);
|
||||
};
|
||||
|
||||
const getRoleLabel = (role: UserRole) => {
|
||||
switch(role) {
|
||||
case UserRole.SUPERADMIN: return "Superadmin";
|
||||
case UserRole.BUSINESS_OWNER: return "Empresa";
|
||||
case UserRole.PHOTOGRAPHER: return "Fotógrafo";
|
||||
case UserRole.EVENT_OWNER: return "Cliente";
|
||||
default: return role;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col lg:flex-row bg-white">
|
||||
{/* Left Side - Image */}
|
||||
<div className="hidden lg:flex lg:w-1/2 relative overflow-hidden">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1519741497674-611481863552?ixlib=rb-1.2.1&auto=format&fit=crop&w=1920&q=80"
|
||||
alt="Photum Login"
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#492E61]/90 to-[#492E61]/70 flex items-center justify-center">
|
||||
<div className="text-center text-white p-12">
|
||||
<h1 className="text-5xl font-serif font-bold mb-4">Photum Formaturas</h1>
|
||||
<p className="text-xl font-light tracking-wide max-w-md mx-auto">Gestão de eventos premium para quem não abre mão da excelência.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side - Form */}
|
||||
<div className="w-full lg:w-1/2 flex items-center justify-center p-4 sm:p-6 md:p-8 lg:p-16">
|
||||
<div className="max-w-md w-full space-y-6 sm:space-y-8 fade-in">
|
||||
<div className="text-center lg:text-left">
|
||||
<span className="font-bold tracking-widest uppercase text-xs sm:text-sm" style={{color: '#B9CF33'}}>Bem-vindo de volta</span>
|
||||
<h2 className="mt-2 text-2xl sm:text-3xl font-serif font-bold text-gray-900">Acesse sua conta</h2>
|
||||
<p className="mt-2 text-xs sm:text-sm text-gray-600">
|
||||
Não tem uma conta?{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate?.('register')}
|
||||
className="font-medium hover:opacity-80 transition-opacity"
|
||||
style={{color: '#B9CF33'}}
|
||||
>
|
||||
Cadastre-se
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="mt-6 sm:mt-8 space-y-4 sm:space-y-6" onSubmit={handleLogin}>
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-1.5 sm:mb-2">
|
||||
E-MAIL CORPORATIVO OU PESSOAL
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
placeholder="nome@exemplo.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-3 sm:px-4 py-2.5 sm:py-3 text-sm sm:text-base border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:border-transparent transition-all"
|
||||
style={{focusRing: '2px solid #B9CF33'}}
|
||||
onFocus={(e) => e.target.style.borderColor = '#B9CF33'}
|
||||
onBlur={(e) => e.target.style.borderColor = '#d1d5db'}
|
||||
/>
|
||||
{error && <span className="text-xs text-red-500 mt-1 block">{error}</span>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-1.5 sm:mb-2">
|
||||
SENHA
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
readOnly
|
||||
className="w-full px-3 sm:px-4 py-2.5 sm:py-3 text-sm sm:text-base border border-gray-300 rounded-lg bg-gray-50 cursor-not-allowed"
|
||||
/>
|
||||
<button type="button" className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full px-6 sm:px-10 py-3 sm:py-4 text-white font-bold text-base sm:text-lg rounded-lg transition-all duration-300 transform hover:scale-[1.02] hover:shadow-xl active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style={{backgroundColor: '#4E345F'}}
|
||||
>
|
||||
{isLoading ? 'Entrando...' : 'Entrar no Sistema'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Demo Users Quick Select */}
|
||||
<div className="mt-6 sm:mt-10 pt-6 sm:pt-10 border-t border-gray-200">
|
||||
<p className="text-[10px] sm:text-xs uppercase tracking-widest mb-3 sm:mb-4 text-center text-gray-400">Usuários de Demonstração (Clique para preencher)</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 sm:gap-3">
|
||||
{availableUsers.map(user => (
|
||||
<button
|
||||
key={user.id}
|
||||
onClick={() => fillCredentials(user.email)}
|
||||
className="flex flex-col items-start p-3 sm:p-4 border-2 rounded-lg hover:bg-gray-50 transition-all duration-300 text-left group transform hover:scale-[1.02]"
|
||||
style={{borderColor: '#e5e7eb'}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#B9CF33';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(185, 207, 51, 0.15)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<span className="text-xs sm:text-sm font-bold text-gray-900">{user.name}</span>
|
||||
<span className="text-[10px] sm:text-xs uppercase tracking-wide mt-0.5 sm:mt-1 font-semibold" style={{color: '#B9CF33'}}>{getRoleLabel(user.role)}</span>
|
||||
<span className="text-[10px] sm:text-xs text-gray-500 mt-0.5 sm:mt-1 truncate w-full">{user.email}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
201
frontend/pages/PrivacyPolicy.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
interface PrivacyPolicyProps {
|
||||
onNavigate: (page: string) => void;
|
||||
}
|
||||
|
||||
interface Section {
|
||||
id: number;
|
||||
title: string;
|
||||
icon: string;
|
||||
content: React.ReactNode;
|
||||
}
|
||||
|
||||
export const PrivacyPolicy: React.FC<PrivacyPolicyProps> = ({ onNavigate }) => {
|
||||
const [openSection, setOpenSection] = useState<number | null>(null);
|
||||
|
||||
const sections: Section[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Informações que Coletamos',
|
||||
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>',
|
||||
content: (
|
||||
<>
|
||||
<p className="mb-4">
|
||||
A Photum Formaturas coleta informações que você nos fornece diretamente ao criar uma conta,
|
||||
solicitar serviços ou entrar em contato conosco. Isso pode incluir:
|
||||
</p>
|
||||
<ul className="list-none space-y-2">
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Nome completo</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Endereço de e-mail</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Número de telefone</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Informações sobre o evento (formatura)</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Dados de pagamento (processados de forma segura)</li>
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Como Usamos suas Informações',
|
||||
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>',
|
||||
content: (
|
||||
<>
|
||||
<p className="mb-4">Utilizamos as informações coletadas para:</p>
|
||||
<ul className="list-none space-y-2">
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Fornecer e gerenciar nossos serviços de fotografia</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Processar pagamentos e enviar confirmações</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Comunicar sobre seus eventos e serviços contratados</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Melhorar nossos serviços e experiência do cliente</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Enviar atualizações e informações relevantes</li>
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Proteção de Dados',
|
||||
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>',
|
||||
content: (
|
||||
<p>
|
||||
Implementamos medidas de segurança técnicas e organizacionais para proteger suas informações
|
||||
pessoais contra acesso não autorizado, alteração, divulgação ou destruição.
|
||||
</p>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Compartilhamento de Informações',
|
||||
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" /></svg>',
|
||||
content: (
|
||||
<p>
|
||||
Não vendemos, alugamos ou compartilhamos suas informações pessoais com terceiros, exceto
|
||||
quando necessário para fornecer nossos serviços ou quando exigido por lei.
|
||||
</p>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'Seus Direitos',
|
||||
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" /></svg>',
|
||||
content: (
|
||||
<>
|
||||
<p className="mb-4">Você tem o direito de:</p>
|
||||
<ul className="list-none space-y-2">
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Acessar suas informações pessoais</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Corrigir dados incorretos</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Solicitar a exclusão de seus dados</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Retirar seu consentimento a qualquer momento</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Solicitar a portabilidade de seus dados</li>
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: 'Contato',
|
||||
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>',
|
||||
content: (
|
||||
<p>
|
||||
Para questões sobre esta Política de Privacidade, entre em contato conosco em:{' '}
|
||||
<a href="mailto:contato@photum.com.br" className="font-semibold hover:opacity-80 transition-opacity" style={{color: '#B9CF33'}}>
|
||||
contato@photum.com.br
|
||||
</a>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-white py-12 sm:py-20 px-4 sm:px-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<button
|
||||
onClick={() => onNavigate('home')}
|
||||
className="mb-8 flex items-center gap-2 text-gray-600 hover:text-[#492E61] transition-all duration-300 group"
|
||||
>
|
||||
<svg className="w-5 h-5 transform group-hover:-translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Voltar para Home
|
||||
</button>
|
||||
|
||||
<div className="text-center mb-12 fade-in">
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold mb-4" style={{ color: '#B9CF33' }}>
|
||||
Política de Privacidade
|
||||
</h1>
|
||||
<p className="text-gray-600 text-lg">Sua privacidade é nossa prioridade</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:gap-6">
|
||||
{sections.map((section, index) => (
|
||||
<div
|
||||
key={section.id}
|
||||
className="bg-white rounded-2xl shadow-lg overflow-hidden transform transition-all duration-500 hover:shadow-2xl"
|
||||
style={{
|
||||
animation: `fadeInUp 0.6s ease-out ${index * 0.1}s both`
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setOpenSection(openSection === section.id ? null : section.id)}
|
||||
className="w-full px-6 sm:px-8 py-6 flex items-center justify-between text-left bg-gradient-to-r from-white to-gray-50 hover:from-gray-50 hover:to-gray-100 transition-all duration-300"
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="transform transition-transform duration-300 hover:scale-110" dangerouslySetInnerHTML={{ __html: section.icon }} />
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-gray-800">
|
||||
{section.title}
|
||||
</h2>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-6 h-6 transition-transform duration-300 ${
|
||||
openSection === section.id ? 'rotate-180' : ''
|
||||
}`}
|
||||
style={{ color: '#492E61' }}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-500 ${
|
||||
openSection === section.id ? 'max-h-[1000px] opacity-100' : 'max-h-0 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="px-6 sm:px-8 py-6 bg-gradient-to-br from-gray-50 to-white border-t border-gray-100">
|
||||
<div className="text-gray-700 leading-relaxed">
|
||||
{section.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-12 text-center p-6 bg-white rounded-2xl shadow-lg">
|
||||
<p className="text-gray-600">
|
||||
<span className="font-semibold" style={{ color: '#492E61' }}>Última atualização:</span> Janeiro de 2025
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeInUp 0.8s ease-out;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
277
frontend/pages/Register.tsx
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '../components/Button';
|
||||
import { Input } from '../components/Input';
|
||||
import { InstitutionForm } from '../components/InstitutionForm';
|
||||
import { useData } from '../contexts/DataContext';
|
||||
|
||||
interface RegisterProps {
|
||||
onNavigate: (page: string) => void;
|
||||
}
|
||||
|
||||
export const Register: React.FC<RegisterProps> = ({ onNavigate }) => {
|
||||
const { addInstitution } = useData();
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
wantsToAddInstitution: false
|
||||
});
|
||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [showInstitutionForm, setShowInstitutionForm] = useState(false);
|
||||
const [tempUserId] = useState(`user-${Date.now()}`);
|
||||
|
||||
const handleChange = (field: string, value: string | boolean) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
// Validação do checkbox de termos
|
||||
if (!agreedToTerms) {
|
||||
setError('Você precisa concordar com os termos de uso para continuar');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validações
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
setError('As senhas não coincidem');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.password.length < 6) {
|
||||
setError('A senha deve ter no mínimo 6 caracteres');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// If user wants to add institution, show form
|
||||
if (formData.wantsToAddInstitution) {
|
||||
setIsLoading(false);
|
||||
setShowInstitutionForm(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Simular registro (conta será criada como Cliente/EVENT_OWNER automaticamente)
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
onNavigate('login');
|
||||
}, 2000);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const handleInstitutionSubmit = (institutionData: any) => {
|
||||
const newInstitution = {
|
||||
...institutionData,
|
||||
id: `inst-${Date.now()}`,
|
||||
ownerId: tempUserId
|
||||
};
|
||||
addInstitution(newInstitution);
|
||||
setShowInstitutionForm(false);
|
||||
|
||||
// Complete registration
|
||||
setIsLoading(true);
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
onNavigate('login');
|
||||
}, 2000);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
// Show institution form modal
|
||||
if (showInstitutionForm) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<InstitutionForm
|
||||
onCancel={() => {
|
||||
// Apenas fecha o modal e volta para o formulário
|
||||
setShowInstitutionForm(false);
|
||||
// Desmarca a opção de cadastrar universidade
|
||||
setFormData(prev => ({ ...prev, wantsToAddInstitution: false }));
|
||||
}}
|
||||
onSubmit={handleInstitutionSubmit}
|
||||
userId={tempUserId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-white">
|
||||
<div className="text-center fade-in">
|
||||
<div className="w-16 h-16 bg-green-500 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Cadastro realizado com sucesso!</h2>
|
||||
<p className="text-gray-600">Redirecionando para o login...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col lg:flex-row bg-white">
|
||||
{/* Left Side - Image */}
|
||||
<div className="hidden lg:flex lg:w-1/2 relative overflow-hidden">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1541339907198-e08756dedf3f?fm=jpg&q=60&w=3000&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
|
||||
alt="Photum Cadastro"
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#492E61]/90 to-[#492E61]/70 flex items-center justify-center">
|
||||
<div className="text-center text-white p-12">
|
||||
<h1 className="text-5xl font-serif font-bold mb-4">Faça parte da Photum</h1>
|
||||
<p className="text-xl font-light tracking-wide max-w-md mx-auto">
|
||||
Eternize seus momentos especiais com a melhor plataforma de gestão de eventos fotográficos.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side - Form */}
|
||||
<div className="w-full lg:w-1/2 flex items-center justify-center p-4 sm:p-6 md:p-8 lg:p-16">
|
||||
<div className="max-w-md w-full space-y-6 sm:space-y-8 fade-in">
|
||||
<div className="text-center lg:text-left">
|
||||
<span className="font-bold tracking-widest uppercase text-xs sm:text-sm" style={{color: '#B9CF33'}}>Comece agora</span>
|
||||
<h2 className="mt-2 text-2xl sm:text-3xl font-serif font-bold text-gray-900">Crie sua conta</h2>
|
||||
<p className="mt-2 text-xs sm:text-sm text-gray-600">
|
||||
Já tem uma conta?{' '}
|
||||
<button
|
||||
onClick={() => onNavigate('login')}
|
||||
className="font-medium hover:opacity-80 transition-opacity"
|
||||
style={{color: '#B9CF33'}}
|
||||
>
|
||||
Faça login
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="mt-6 sm:mt-8 space-y-4 sm:space-y-5" onSubmit={handleSubmit}>
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<Input
|
||||
label="Nome Completo"
|
||||
type="text"
|
||||
required
|
||||
placeholder="João Silva"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="E-mail"
|
||||
type="email"
|
||||
required
|
||||
placeholder="nome@exemplo.com"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Telefone"
|
||||
type="tel"
|
||||
required
|
||||
placeholder="(00) 00000-0000"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleChange('phone', e.target.value)}
|
||||
mask="phone"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Senha"
|
||||
type="password"
|
||||
required
|
||||
placeholder="••••••••"
|
||||
value={formData.password}
|
||||
onChange={(e) => handleChange('password', e.target.value)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Confirmar Senha"
|
||||
type="password"
|
||||
required
|
||||
placeholder="••••••••"
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => handleChange('confirmPassword', e.target.value)}
|
||||
error={error && (error.includes('senha') || error.includes('coincidem')) ? error : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-start">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={agreedToTerms}
|
||||
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
||||
className="mt-0.5 sm:mt-1 h-4 w-4 flex-shrink-0 border-gray-300 rounded focus:ring-2"
|
||||
style={{accentColor: '#B9CF33'}}
|
||||
/>
|
||||
<label className="ml-2 text-xs sm:text-sm text-gray-600">
|
||||
Concordo com os{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate('terms')}
|
||||
className="hover:opacity-80 transition-opacity underline"
|
||||
style={{color: '#B9CF33'}}
|
||||
>
|
||||
termos de uso
|
||||
</button>{' '}
|
||||
e{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate('privacy')}
|
||||
className="hover:opacity-80 transition-opacity underline"
|
||||
style={{color: '#B9CF33'}}
|
||||
>
|
||||
política de privacidade
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
{error && error.includes('termos') && (
|
||||
<span className="text-xs text-red-500 mt-1 block ml-6">{error}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start bg-gray-50 border border-gray-200 rounded-lg p-3 sm:p-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.wantsToAddInstitution}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, wantsToAddInstitution: e.target.checked }))}
|
||||
className="mt-0.5 sm:mt-1 h-4 w-4 flex-shrink-0 border-gray-300 rounded focus:ring-2"
|
||||
style={{accentColor: '#B9CF33'}}
|
||||
/>
|
||||
<label className="ml-2 text-xs sm:text-sm text-gray-700">
|
||||
<span className="font-medium text-xs sm:text-sm">Cadastrar universidade agora (Opcional)</span>
|
||||
<p className="text-[10px] sm:text-xs text-gray-500 mt-1">
|
||||
Você pode cadastrar sua universidade durante o cadastro ou posteriormente no sistema.
|
||||
Trabalhamos exclusivamente com eventos fotográficos em universidades.
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" size="lg" isLoading={isLoading}>
|
||||
{formData.wantsToAddInstitution ? 'Continuar para Universidade' : 'Criar Conta'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
482
frontend/pages/Settings.tsx
Normal file
|
|
@ -0,0 +1,482 @@
|
|||
import React, { useState } from 'react';
|
||||
import { User, Mail, Phone, MapPin, Lock, Bell, Palette, Globe, Save, Camera } from 'lucide-react';
|
||||
import { Button } from '../components/Button';
|
||||
|
||||
export const SettingsPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'profile' | 'account' | 'notifications' | 'appearance'>('profile');
|
||||
const [profileData, setProfileData] = useState({
|
||||
name: 'João Silva',
|
||||
email: 'joao.silva@photum.com',
|
||||
phone: '(41) 99999-0000',
|
||||
location: 'Curitiba, PR',
|
||||
bio: 'Fotógrafo profissional especializado em eventos e formaturas há mais de 10 anos.',
|
||||
avatar: 'https://i.pravatar.cc/150?img=68'
|
||||
});
|
||||
|
||||
const [notificationSettings, setNotificationSettings] = useState({
|
||||
emailNotifications: true,
|
||||
pushNotifications: true,
|
||||
smsNotifications: false,
|
||||
eventReminders: true,
|
||||
paymentAlerts: true,
|
||||
teamUpdates: false
|
||||
});
|
||||
|
||||
const [appearanceSettings, setAppearanceSettings] = useState({
|
||||
theme: 'light',
|
||||
language: 'pt-BR',
|
||||
dateFormat: 'DD/MM/YYYY',
|
||||
currency: 'BRL'
|
||||
});
|
||||
|
||||
const handleSaveProfile = () => {
|
||||
alert('Perfil atualizado com sucesso!');
|
||||
};
|
||||
|
||||
const handleSaveNotifications = () => {
|
||||
alert('Configurações de notificações salvas!');
|
||||
};
|
||||
|
||||
const handleSaveAppearance = () => {
|
||||
alert('Configurações de aparência salvas!');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pt-32 pb-12">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-serif font-bold text-brand-black mb-2">
|
||||
Configurações
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
Gerencie suas preferências e informações da conta
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Sidebar */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<nav className="space-y-1">
|
||||
<button
|
||||
onClick={() => setActiveTab('profile')}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-md transition-colors ${activeTab === 'profile'
|
||||
? 'bg-brand-gold text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<User size={20} />
|
||||
<span className="font-medium">Perfil</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('account')}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-md transition-colors ${activeTab === 'account'
|
||||
? 'bg-brand-gold text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Lock size={20} />
|
||||
<span className="font-medium">Conta</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('notifications')}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-md transition-colors ${activeTab === 'notifications'
|
||||
? 'bg-brand-gold text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Bell size={20} />
|
||||
<span className="font-medium">Notificações</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('appearance')}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-md transition-colors ${activeTab === 'appearance'
|
||||
? 'bg-brand-gold text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Palette size={20} />
|
||||
<span className="font-medium">Aparência</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="lg:col-span-3">
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-8">
|
||||
{/* Profile Tab */}
|
||||
{activeTab === 'profile' && (
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold mb-6">Informações do Perfil</h2>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="relative">
|
||||
<img
|
||||
src={profileData.avatar}
|
||||
alt="Avatar"
|
||||
className="w-24 h-24 rounded-full object-cover"
|
||||
/>
|
||||
<button className="absolute bottom-0 right-0 w-8 h-8 bg-brand-gold text-white rounded-full flex items-center justify-center hover:bg-[#a5bd2e] transition-colors">
|
||||
<Camera size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">{profileData.name}</h3>
|
||||
<p className="text-sm text-gray-600">{profileData.email}</p>
|
||||
<button className="text-sm text-brand-gold hover:underline mt-1">
|
||||
Alterar foto
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nome Completo
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
||||
<input
|
||||
type="text"
|
||||
value={profileData.name}
|
||||
onChange={(e) => setProfileData({ ...profileData, name: e.target.value })}
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
||||
<input
|
||||
type="email"
|
||||
value={profileData.email}
|
||||
onChange={(e) => setProfileData({ ...profileData, email: e.target.value })}
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Telefone
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
||||
<input
|
||||
type="tel"
|
||||
value={profileData.phone}
|
||||
onChange={(e) => setProfileData({ ...profileData, phone: e.target.value })}
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Localização
|
||||
</label>
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
||||
<input
|
||||
type="text"
|
||||
value={profileData.location}
|
||||
onChange={(e) => setProfileData({ ...profileData, location: e.target.value })}
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Biografia
|
||||
</label>
|
||||
<textarea
|
||||
value={profileData.bio}
|
||||
onChange={(e) => setProfileData({ ...profileData, bio: e.target.value })}
|
||||
rows={4}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<Button size="lg" variant="secondary" onClick={handleSaveProfile}>
|
||||
<Save size={20} className="mr-2" />
|
||||
Salvar Alterações
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account Tab */}
|
||||
{activeTab === 'account' && (
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold mb-6">Segurança da Conta</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Senha Atual
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Digite sua senha atual"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nova Senha
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Digite sua nova senha"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Confirmar Nova Senha
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Confirme sua nova senha"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<Button size="lg" variant="secondary">
|
||||
<Lock size={20} className="mr-2" />
|
||||
Atualizar Senha
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="pt-8 border-t border-gray-200">
|
||||
<h3 className="text-lg font-semibold mb-4">Autenticação em Dois Fatores</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Adicione uma camada extra de segurança à sua conta
|
||||
</p>
|
||||
<Button size="md" variant="outline">
|
||||
Ativar 2FA
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notifications Tab */}
|
||||
{activeTab === 'notifications' && (
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold mb-6">Preferências de Notificações</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between py-4 border-b border-gray-200">
|
||||
<div>
|
||||
<h3 className="font-medium">Notificações por Email</h3>
|
||||
<p className="text-sm text-gray-600">Receba atualizações por email</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationSettings.emailNotifications}
|
||||
onChange={(e) => setNotificationSettings({
|
||||
...notificationSettings,
|
||||
emailNotifications: e.target.checked
|
||||
})}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-gold/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-brand-gold"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-4 border-b border-gray-200">
|
||||
<div>
|
||||
<h3 className="font-medium">Notificações Push</h3>
|
||||
<p className="text-sm text-gray-600">Receba notificações no navegador</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationSettings.pushNotifications}
|
||||
onChange={(e) => setNotificationSettings({
|
||||
...notificationSettings,
|
||||
pushNotifications: e.target.checked
|
||||
})}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-gold/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-brand-gold"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-4 border-b border-gray-200">
|
||||
<div>
|
||||
<h3 className="font-medium">SMS</h3>
|
||||
<p className="text-sm text-gray-600">Receba mensagens de texto</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationSettings.smsNotifications}
|
||||
onChange={(e) => setNotificationSettings({
|
||||
...notificationSettings,
|
||||
smsNotifications: e.target.checked
|
||||
})}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-gold/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-brand-gold"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-4 border-b border-gray-200">
|
||||
<div>
|
||||
<h3 className="font-medium">Lembretes de Eventos</h3>
|
||||
<p className="text-sm text-gray-600">Receba lembretes antes dos eventos</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationSettings.eventReminders}
|
||||
onChange={(e) => setNotificationSettings({
|
||||
...notificationSettings,
|
||||
eventReminders: e.target.checked
|
||||
})}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-gold/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-brand-gold"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-4 border-b border-gray-200">
|
||||
<div>
|
||||
<h3 className="font-medium">Alertas de Pagamento</h3>
|
||||
<p className="text-sm text-gray-600">Notificações sobre pagamentos</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationSettings.paymentAlerts}
|
||||
onChange={(e) => setNotificationSettings({
|
||||
...notificationSettings,
|
||||
paymentAlerts: e.target.checked
|
||||
})}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-gold/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-brand-gold"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<Button size="lg" variant="secondary" onClick={handleSaveNotifications}>
|
||||
<Save size={20} className="mr-2" />
|
||||
Salvar Preferências
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Appearance Tab */}
|
||||
{activeTab === 'appearance' && (
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold mb-6">Aparência e Idioma</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Tema
|
||||
</label>
|
||||
<select
|
||||
value={appearanceSettings.theme}
|
||||
onChange={(e) => setAppearanceSettings({
|
||||
...appearanceSettings,
|
||||
theme: e.target.value
|
||||
})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||
>
|
||||
<option value="light">Claro</option>
|
||||
<option value="dark">Escuro</option>
|
||||
<option value="auto">Automático</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Idioma
|
||||
</label>
|
||||
<select
|
||||
value={appearanceSettings.language}
|
||||
onChange={(e) => setAppearanceSettings({
|
||||
...appearanceSettings,
|
||||
language: e.target.value
|
||||
})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||
>
|
||||
<option value="pt-BR">Português (Brasil)</option>
|
||||
<option value="en-US">English (US)</option>
|
||||
<option value="es-ES">Español</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Formato de Data
|
||||
</label>
|
||||
<select
|
||||
value={appearanceSettings.dateFormat}
|
||||
onChange={(e) => setAppearanceSettings({
|
||||
...appearanceSettings,
|
||||
dateFormat: e.target.value
|
||||
})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||
>
|
||||
<option value="DD/MM/YYYY">DD/MM/YYYY</option>
|
||||
<option value="MM/DD/YYYY">MM/DD/YYYY</option>
|
||||
<option value="YYYY-MM-DD">YYYY-MM-DD</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Moeda
|
||||
</label>
|
||||
<select
|
||||
value={appearanceSettings.currency}
|
||||
onChange={(e) => setAppearanceSettings({
|
||||
...appearanceSettings,
|
||||
currency: e.target.value
|
||||
})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||
>
|
||||
<option value="BRL">Real (R$)</option>
|
||||
<option value="USD">Dólar ($)</option>
|
||||
<option value="EUR">Euro (€)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<Button size="lg" variant="secondary" onClick={handleSaveAppearance}>
|
||||
<Save size={20} className="mr-2" />
|
||||
Salvar Configurações
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
759
frontend/pages/Team.tsx
Normal file
|
|
@ -0,0 +1,759 @@
|
|||
import React, { useState } from "react";
|
||||
import {
|
||||
Users,
|
||||
Camera,
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin,
|
||||
Star,
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
User,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Button } from "../components/Button";
|
||||
|
||||
interface Photographer {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
location: string;
|
||||
specialties: string[];
|
||||
rating: number;
|
||||
eventsCompleted: number;
|
||||
status: "active" | "inactive" | "busy";
|
||||
avatar: string;
|
||||
joinDate: string;
|
||||
}
|
||||
|
||||
const MOCK_PHOTOGRAPHERS: Photographer[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Carlos Silva",
|
||||
email: "carlos.silva@photum.com",
|
||||
phone: "(41) 99999-1111",
|
||||
location: "Curitiba, PR",
|
||||
specialties: ["Formaturas", "Eventos Corporativos"],
|
||||
rating: 4.8,
|
||||
eventsCompleted: 45,
|
||||
status: "active",
|
||||
avatar: "https://i.pravatar.cc/150?img=12",
|
||||
joinDate: "2023-01-15",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Ana Paula Mendes",
|
||||
email: "ana.mendes@photum.com",
|
||||
phone: "(41) 99999-2222",
|
||||
location: "Curitiba, PR",
|
||||
specialties: ["Casamentos", "Formaturas"],
|
||||
rating: 4.9,
|
||||
eventsCompleted: 62,
|
||||
status: "busy",
|
||||
avatar: "https://i.pravatar.cc/150?img=5",
|
||||
joinDate: "2022-08-20",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "Roberto Costa",
|
||||
email: "roberto.costa@photum.com",
|
||||
phone: "(41) 99999-3333",
|
||||
location: "São José dos Pinhais, PR",
|
||||
specialties: ["Formaturas", "Eventos Sociais"],
|
||||
rating: 4.7,
|
||||
eventsCompleted: 38,
|
||||
status: "active",
|
||||
avatar: "https://i.pravatar.cc/150?img=33",
|
||||
joinDate: "2023-03-10",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "Juliana Santos",
|
||||
email: "juliana.santos@photum.com",
|
||||
phone: "(41) 99999-4444",
|
||||
location: "Curitiba, PR",
|
||||
specialties: ["Casamentos", "Ensaios"],
|
||||
rating: 5.0,
|
||||
eventsCompleted: 71,
|
||||
status: "active",
|
||||
avatar: "https://i.pravatar.cc/150?img=9",
|
||||
joinDate: "2022-05-12",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
name: "Fernando Oliveira",
|
||||
email: "fernando.oliveira@photum.com",
|
||||
phone: "(41) 99999-5555",
|
||||
location: "Pinhais, PR",
|
||||
specialties: ["Eventos Corporativos", "Formaturas"],
|
||||
rating: 4.6,
|
||||
eventsCompleted: 29,
|
||||
status: "inactive",
|
||||
avatar: "https://i.pravatar.cc/150?img=15",
|
||||
joinDate: "2023-07-01",
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
name: "Mariana Rodrigues",
|
||||
email: "mariana.rodrigues@photum.com",
|
||||
phone: "(41) 99999-6666",
|
||||
location: "Curitiba, PR",
|
||||
specialties: ["Formaturas", "Eventos Sociais", "Casamentos"],
|
||||
rating: 4.9,
|
||||
eventsCompleted: 54,
|
||||
status: "busy",
|
||||
avatar: "https://i.pravatar.cc/150?img=10",
|
||||
joinDate: "2022-11-05",
|
||||
},
|
||||
];
|
||||
|
||||
export const TeamPage: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<
|
||||
"all" | "active" | "busy" | "inactive"
|
||||
>("all");
|
||||
const [selectedPhotographer, setSelectedPhotographer] =
|
||||
useState<Photographer | null>(null);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [newPhotographer, setNewPhotographer] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
location: "",
|
||||
specialties: [] as string[],
|
||||
avatar: "",
|
||||
});
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [avatarPreview, setAvatarPreview] = useState<string>("");
|
||||
|
||||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setAvatarFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setAvatarPreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const removeAvatar = () => {
|
||||
setAvatarFile(null);
|
||||
setAvatarPreview("");
|
||||
};
|
||||
|
||||
const getStatusColor = (status: Photographer["status"]) => {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "bg-green-100 text-green-800";
|
||||
case "busy":
|
||||
return "bg-yellow-100 text-yellow-800";
|
||||
case "inactive":
|
||||
return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: Photographer["status"]) => {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "Disponível";
|
||||
case "busy":
|
||||
return "Em Evento";
|
||||
case "inactive":
|
||||
return "Inativo";
|
||||
}
|
||||
};
|
||||
|
||||
const filteredPhotographers = MOCK_PHOTOGRAPHERS.filter((photographer) => {
|
||||
const matchesSearch =
|
||||
photographer.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
photographer.email.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesStatus =
|
||||
statusFilter === "all" || photographer.status === statusFilter;
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: MOCK_PHOTOGRAPHERS.length,
|
||||
active: MOCK_PHOTOGRAPHERS.filter((p) => p.status === "active").length,
|
||||
busy: MOCK_PHOTOGRAPHERS.filter((p) => p.status === "busy").length,
|
||||
avgRating: (
|
||||
MOCK_PHOTOGRAPHERS.reduce((acc, p) => acc + p.rating, 0) /
|
||||
MOCK_PHOTOGRAPHERS.length
|
||||
).toFixed(1),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pt-32 pb-12">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-serif font-bold text-brand-black mb-2">
|
||||
Equipe & Fotógrafos
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
Gerencie sua equipe de fotógrafos profissionais
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
Total de Fotógrafos
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-brand-black">
|
||||
{stats.total}
|
||||
</p>
|
||||
</div>
|
||||
<Users className="text-brand-gold" size={32} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">Disponíveis</p>
|
||||
<p className="text-3xl font-bold text-green-600">
|
||||
{stats.active}
|
||||
</p>
|
||||
</div>
|
||||
<Camera className="text-green-600" size={32} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">Em Evento</p>
|
||||
<p className="text-3xl font-bold text-yellow-600">
|
||||
{stats.busy}
|
||||
</p>
|
||||
</div>
|
||||
<Camera className="text-yellow-600" size={32} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">Avaliação Média</p>
|
||||
<p className="text-3xl font-bold text-brand-gold">
|
||||
{stats.avgRating}
|
||||
</p>
|
||||
</div>
|
||||
<Star className="text-brand-gold" size={32} fill="#B9CF33" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
||||
size={20}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por nome ou email..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setStatusFilter("all")}
|
||||
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
||||
statusFilter === "all"
|
||||
? "bg-brand-gold text-white"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
Todos
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter("active")}
|
||||
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
||||
statusFilter === "active"
|
||||
? "bg-green-600 text-white"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
Disponíveis
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter("busy")}
|
||||
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
||||
statusFilter === "busy"
|
||||
? "bg-yellow-600 text-white"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
Em Evento
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
size="md"
|
||||
variant="secondary"
|
||||
onClick={() => setShowAddModal(true)}
|
||||
>
|
||||
<Plus size={20} className="mr-2" />
|
||||
Adicionar Fotógrafo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Photographers Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredPhotographers.map((photographer) => (
|
||||
<div
|
||||
key={photographer.id}
|
||||
className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={() => setSelectedPhotographer(photographer)}
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={photographer.avatar}
|
||||
alt={photographer.name}
|
||||
className="w-16 h-16 rounded-full object-cover"
|
||||
/>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg text-brand-black">
|
||||
{photographer.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<Star
|
||||
size={14}
|
||||
fill="#B9CF33"
|
||||
className="text-brand-gold"
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{photographer.rating}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
({photographer.eventsCompleted} eventos)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(
|
||||
photographer.status
|
||||
)}`}
|
||||
>
|
||||
{getStatusLabel(photographer.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<Mail size={16} className="mr-2 text-brand-gold" />
|
||||
{photographer.email}
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<Phone size={16} className="mr-2 text-brand-gold" />
|
||||
{photographer.phone}
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<MapPin size={16} className="mr-2 text-brand-gold" />
|
||||
{photographer.location}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{photographer.specialties.map((specialty, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 bg-gray-100 text-gray-700 rounded-full text-xs font-medium"
|
||||
>
|
||||
{specialty}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredPhotographers.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Users size={48} className="mx-auto text-gray-300 mb-4" />
|
||||
<p className="text-gray-500">Nenhum fotógrafo encontrado</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Photographer Modal */}
|
||||
{showAddModal && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
onClick={() => setShowAddModal(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-lg max-w-2xl w-full p-8 max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-serif font-bold text-brand-black">
|
||||
Adicionar Novo Fotógrafo
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowAddModal(false)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="space-y-6"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
alert(
|
||||
"Fotógrafo adicionado com sucesso!\n\n" +
|
||||
JSON.stringify(
|
||||
{ ...newPhotographer, avatarFile: avatarFile?.name },
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
setShowAddModal(false);
|
||||
setNewPhotographer({
|
||||
name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
location: "",
|
||||
specialties: [],
|
||||
avatar: "",
|
||||
});
|
||||
setAvatarFile(null);
|
||||
setAvatarPreview("");
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Foto de Perfil
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
{avatarPreview ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={avatarPreview}
|
||||
alt="Preview"
|
||||
className="w-24 h-24 rounded-full object-cover border-2 border-gray-200"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeAvatar}
|
||||
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 hover:bg-red-600 transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-24 h-24 rounded-full bg-gray-100 border-2 border-dashed border-gray-300 flex items-center justify-center">
|
||||
<Camera size={32} className="text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<label className="cursor-pointer">
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-gray-50 border border-gray-300 rounded-md hover:bg-gray-100 transition-colors w-fit">
|
||||
<Upload size={18} className="text-gray-600" />
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{avatarFile ? "Trocar foto" : "Selecionar foto"}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
JPG, PNG ou GIF (máx. 5MB)
|
||||
</p>
|
||||
{avatarFile && (
|
||||
<p className="text-xs text-brand-gold mt-1 font-medium">
|
||||
{avatarFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nome Completo *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User
|
||||
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
||||
size={20}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={newPhotographer.name}
|
||||
onChange={(e) =>
|
||||
setNewPhotographer({
|
||||
...newPhotographer,
|
||||
name: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Ex: João Silva"
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail
|
||||
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
||||
size={20}
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={newPhotographer.email}
|
||||
onChange={(e) =>
|
||||
setNewPhotographer({
|
||||
...newPhotographer,
|
||||
email: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="joao.silva@photum.com"
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Telefone *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Phone
|
||||
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
||||
size={20}
|
||||
/>
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
value={newPhotographer.phone}
|
||||
onChange={(e) =>
|
||||
setNewPhotographer({
|
||||
...newPhotographer,
|
||||
phone: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="(41) 99999-0000"
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Localização *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<MapPin
|
||||
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
||||
size={20}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={newPhotographer.location}
|
||||
onChange={(e) =>
|
||||
setNewPhotographer({
|
||||
...newPhotographer,
|
||||
location: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Curitiba, PR"
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Especialidades
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
"Formaturas",
|
||||
"Casamentos",
|
||||
"Eventos Corporativos",
|
||||
"Eventos Sociais",
|
||||
"Ensaios",
|
||||
].map((specialty) => (
|
||||
<label
|
||||
key={specialty}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newPhotographer.specialties.includes(
|
||||
specialty
|
||||
)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setNewPhotographer({
|
||||
...newPhotographer,
|
||||
specialties: [
|
||||
...newPhotographer.specialties,
|
||||
specialty,
|
||||
],
|
||||
});
|
||||
} else {
|
||||
setNewPhotographer({
|
||||
...newPhotographer,
|
||||
specialties: newPhotographer.specialties.filter(
|
||||
(s) => s !== specialty
|
||||
),
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 text-brand-gold focus:ring-brand-gold border-gray-300 rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">{specialty}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 border-t border-gray-200 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddModal(false)}
|
||||
className="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors font-medium"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-6 py-3 bg-brand-gold text-white rounded-md hover:bg-[#a5bd2e] transition-colors font-medium"
|
||||
>
|
||||
Adicionar Fotógrafo
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Photographer Detail Modal */}
|
||||
{selectedPhotographer && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
onClick={() => setSelectedPhotographer(null)}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-lg max-w-2xl w-full p-8"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<img
|
||||
src={selectedPhotographer.avatar}
|
||||
alt={selectedPhotographer.name}
|
||||
className="w-24 h-24 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-serif font-bold text-brand-black mb-1">
|
||||
{selectedPhotographer.name}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Star
|
||||
size={18}
|
||||
fill="#B9CF33"
|
||||
className="text-brand-gold"
|
||||
/>
|
||||
<span className="font-semibold">
|
||||
{selectedPhotographer.rating}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
({selectedPhotographer.eventsCompleted} eventos
|
||||
concluídos)
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`inline-block px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(
|
||||
selectedPhotographer.status
|
||||
)}`}
|
||||
>
|
||||
{getStatusLabel(selectedPhotographer.status)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedPhotographer(null)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="flex items-center text-gray-700">
|
||||
<Mail size={20} className="mr-3 text-brand-gold" />
|
||||
<span>{selectedPhotographer.email}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-gray-700">
|
||||
<Phone size={20} className="mr-3 text-brand-gold" />
|
||||
<span>{selectedPhotographer.phone}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-gray-700">
|
||||
<MapPin size={20} className="mr-3 text-brand-gold" />
|
||||
<span>{selectedPhotographer.location}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="font-semibold mb-2">Especialidades</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedPhotographer.specialties.map((specialty, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-3 py-1 bg-brand-gold/10 text-brand-gold rounded-full text-sm font-medium"
|
||||
>
|
||||
{specialty}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 border-t border-gray-200 flex gap-3">
|
||||
<button
|
||||
onClick={() => setSelectedPhotographer(null)}
|
||||
className="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors font-medium"
|
||||
>
|
||||
Fechar
|
||||
</button>
|
||||
<button className="flex-1 px-6 py-3 bg-brand-gold text-white rounded-md hover:bg-[#a5bd2e] transition-colors font-medium">
|
||||
Ver Agenda
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
220
frontend/pages/TermsOfUse.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
interface TermsOfUseProps {
|
||||
onNavigate: (page: string) => void;
|
||||
}
|
||||
|
||||
interface Section {
|
||||
id: number;
|
||||
title: string;
|
||||
icon: string;
|
||||
content: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TermsOfUse: React.FC<TermsOfUseProps> = ({ onNavigate }) => {
|
||||
const [openSection, setOpenSection] = useState<number | null>(null);
|
||||
|
||||
const sections: Section[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Aceitação dos Termos',
|
||||
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>',
|
||||
content: (
|
||||
<p>
|
||||
Ao acessar e usar os serviços da Photum Formaturas, você concorda em cumprir e estar vinculado
|
||||
a estes Termos de Uso. Se você não concordar com algum destes termos, não utilize nossos serviços.
|
||||
</p>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Descrição dos Serviços',
|
||||
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" /></svg>',
|
||||
content: (
|
||||
<>
|
||||
<p className="mb-4">A Photum Formaturas oferece:</p>
|
||||
<ul className="list-none space-y-2">
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Serviços de fotografia para formaturas</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Álbuns digitais e físicos</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Galeria online para visualização e download de fotos</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Serviços personalizados de acordo com as necessidades do cliente</li>
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Conta de Usuário',
|
||||
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>',
|
||||
content: (
|
||||
<>
|
||||
<p className="mb-4">Para acessar alguns recursos, você precisará criar uma conta. Você concorda em:</p>
|
||||
<ul className="list-none space-y-2">
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Fornecer informações precisas e atualizadas</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Manter a segurança de sua senha</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Notificar-nos sobre qualquer uso não autorizado de sua conta</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Ser responsável por todas as atividades em sua conta</li>
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Direitos de Propriedade Intelectual',
|
||||
icon: '<svg className="w-12 h-12" fill="none" stroke="#492E61" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" strokeWidth={2} /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 9a3 3 0 00-3-3 3 3 0 00-3 3m0 6a3 3 0 003 3 3 3 0 003-3" /></svg>',
|
||||
content: (
|
||||
<p>
|
||||
Todas as fotografias e conteúdos produzidos pela Photum Formaturas são protegidos por direitos autorais.
|
||||
Os clientes recebem uma licença para uso pessoal das fotos adquiridas, mas a reprodução comercial
|
||||
requer autorização prévia.
|
||||
</p>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'Pagamentos e Reembolsos',
|
||||
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" /></svg>',
|
||||
content: (
|
||||
<>
|
||||
<p className="mb-4">Nossos termos de pagamento incluem:</p>
|
||||
<ul className="list-none space-y-2">
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Pagamento parcial na reserva do serviço</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Saldo remanescente até a data do evento</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Reembolsos disponíveis conforme política específica</li>
|
||||
<li className="flex items-center gap-2"><span className="text-[#B9CF33]">✓</span> Cancelamentos devem ser notificados com antecedência mínima</li>
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: 'Responsabilidades e Limitações',
|
||||
icon: '<svg className="w-12 h-12" fill="none" stroke="#FFA500" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>',
|
||||
content: (
|
||||
<p>
|
||||
A Photum Formaturas se esforça para fornecer serviços de alta qualidade, mas não se responsabiliza
|
||||
por circunstâncias além de nosso controle, como condições climáticas adversas ou problemas técnicos
|
||||
no local do evento.
|
||||
</p>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: 'Modificações nos Termos',
|
||||
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>',
|
||||
content: (
|
||||
<p>
|
||||
Reservamo-nos o direito de modificar estes Termos de Uso a qualquer momento. Alterações significativas
|
||||
serão notificadas aos usuários. O uso continuado dos serviços após mudanças constitui aceitação dos
|
||||
novos termos.
|
||||
</p>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
title: 'Contato',
|
||||
icon: '<svg className="w-12 h-12" fill="none" stroke="#B9CF33" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" /></svg>',
|
||||
content: (
|
||||
<p>
|
||||
Para dúvidas sobre estes Termos de Uso, entre em contato conosco em:{' '}
|
||||
<a href="mailto:contato@photum.com.br" className="font-semibold hover:opacity-80 transition-opacity" style={{color: '#B9CF33'}}>
|
||||
contato@photum.com.br
|
||||
</a>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-white py-12 sm:py-20 px-4 sm:px-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<button
|
||||
onClick={() => onNavigate('home')}
|
||||
className="mb-8 flex items-center gap-2 text-gray-600 hover:text-[#492E61] transition-all duration-300 group"
|
||||
>
|
||||
<svg className="w-5 h-5 transform group-hover:-translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Voltar para Home
|
||||
</button>
|
||||
|
||||
<div className="text-center mb-12 fade-in">
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold mb-4" style={{ color: '#B9CF33' }}>
|
||||
Termos de Uso
|
||||
</h1>
|
||||
<p className="text-gray-600 text-lg">Condições para uso de nossos serviços</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:gap-6">
|
||||
{sections.map((section, index) => (
|
||||
<div
|
||||
key={section.id}
|
||||
className="bg-white rounded-2xl shadow-lg overflow-hidden transform transition-all duration-500 hover:shadow-2xl"
|
||||
style={{
|
||||
animation: `fadeInUp 0.6s ease-out ${index * 0.1}s both`
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setOpenSection(openSection === section.id ? null : section.id)}
|
||||
className="w-full px-6 sm:px-8 py-6 flex items-center justify-between text-left bg-gradient-to-r from-white to-gray-50 hover:from-gray-50 hover:to-gray-100 transition-all duration-300"
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="transform transition-transform duration-300 hover:scale-110" dangerouslySetInnerHTML={{ __html: section.icon }} />
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-gray-800">
|
||||
{section.title}
|
||||
</h2>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-6 h-6 transition-transform duration-300 ${
|
||||
openSection === section.id ? 'rotate-180' : ''
|
||||
}`}
|
||||
style={{ color: '#492E61' }}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-500 ${
|
||||
openSection === section.id ? 'max-h-[1000px] opacity-100' : 'max-h-0 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="px-6 sm:px-8 py-6 bg-gradient-to-br from-gray-50 to-white border-t border-gray-100">
|
||||
<div className="text-gray-700 leading-relaxed">
|
||||
{section.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-12 text-center p-6 bg-white rounded-2xl shadow-lg">
|
||||
<p className="text-gray-600">
|
||||
<span className="font-semibold" style={{ color: '#492E61' }}>Última atualização:</span> Janeiro de 2025
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeInUp 0.8s ease-out;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
BIN
frontend/public/HOME_01.jpg
Normal file
|
After Width: | Height: | Size: 711 KiB |
BIN
frontend/public/HOME_14.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
frontend/public/HOME_15.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
frontend/public/HOME_16.png
Normal file
|
After Width: | Height: | Size: 933 KiB |
BIN
frontend/public/HOME_17 (1).png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
frontend/public/HOME_17.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
frontend/public/banner2.jpg
Normal file
|
After Width: | Height: | Size: 510 KiB |
BIN
frontend/public/favicon.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
frontend/public/logo.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
frontend/public/logo23.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
frontend/public/logofav.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
23
frontend/services/genaiService.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// Gemini API service temporarily disabled - requires API key configuration
|
||||
// import { GoogleGenAI } from "@google/genai";
|
||||
// const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
|
||||
|
||||
export interface GeoResult {
|
||||
street: string;
|
||||
number: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zip: string;
|
||||
description: string;
|
||||
mapLink?: string;
|
||||
}
|
||||
|
||||
// Temporarily disabled - requires API key setup
|
||||
export const searchLocationWithGemini = async (
|
||||
query: string
|
||||
): Promise<GeoResult[]> => {
|
||||
console.warn(
|
||||
"Gemini location search is disabled. Please configure API key to enable."
|
||||
);
|
||||
return [];
|
||||
};
|
||||
156
frontend/services/mapboxService.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
// Mapbox Geocoding Service
|
||||
// Docs: https://docs.mapbox.com/api/search/geocoding/
|
||||
|
||||
export interface MapboxFeature {
|
||||
id: string;
|
||||
place_name: string;
|
||||
center: [number, number]; // [longitude, latitude]
|
||||
geometry: {
|
||||
coordinates: [number, number];
|
||||
};
|
||||
context?: Array<{
|
||||
id: string;
|
||||
text: string;
|
||||
short_code?: string;
|
||||
}>;
|
||||
place_type: string[];
|
||||
text: string;
|
||||
address?: string;
|
||||
}
|
||||
|
||||
export interface MapboxResult {
|
||||
description: string;
|
||||
street: string;
|
||||
number: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zip: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
mapLink: string;
|
||||
}
|
||||
|
||||
// Token do Mapbox configurado no arquivo .env.local
|
||||
const MAPBOX_TOKEN = import.meta.env.VITE_MAPBOX_TOKEN || "";
|
||||
|
||||
/**
|
||||
* Busca endereços usando a API de Geocoding do Mapbox
|
||||
* @param query - Texto de busca (endereço, local, etc)
|
||||
* @param country - Código do país (ex: 'br' para Brasil)
|
||||
*/
|
||||
export async function searchMapboxLocation(
|
||||
query: string,
|
||||
country: string = "br"
|
||||
): Promise<MapboxResult[]> {
|
||||
if (!MAPBOX_TOKEN || MAPBOX_TOKEN.startsWith("YOUR_")) {
|
||||
console.warn(
|
||||
"⚠️ Mapbox Token não configurado. Configure em services/mapboxService.ts"
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const encodedQuery = encodeURIComponent(query);
|
||||
const url =
|
||||
`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodedQuery}.json?` +
|
||||
`access_token=${MAPBOX_TOKEN}&` +
|
||||
`country=${country}&` +
|
||||
`language=pt&` +
|
||||
`limit=5`;
|
||||
|
||||
console.log("🔍 Buscando endereço:", query);
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("❌ Erro na API Mapbox:", response.statusText);
|
||||
throw new Error(`Mapbox API error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("✅ Resultados encontrados:", data.features?.length || 0);
|
||||
|
||||
if (!data.features || data.features.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.features.map((feature: MapboxFeature) => {
|
||||
// Extrair informações do contexto
|
||||
const context = feature.context || [];
|
||||
const place = context.find((c) => c.id.startsWith("place"));
|
||||
const region = context.find((c) => c.id.startsWith("region"));
|
||||
const postcode = context.find((c) => c.id.startsWith("postcode"));
|
||||
|
||||
// Extrair número do endereço
|
||||
const addressMatch =
|
||||
feature.address || feature.text.match(/\d+/)?.[0] || "";
|
||||
|
||||
return {
|
||||
description: feature.place_name,
|
||||
street: feature.text,
|
||||
number: addressMatch,
|
||||
city: place?.text || "",
|
||||
state: region?.short_code?.replace("BR-", "") || region?.text || "",
|
||||
zip: postcode?.text || "",
|
||||
lat: feature.center[1],
|
||||
lng: feature.center[0],
|
||||
mapLink: `https://www.google.com/maps/search/?api=1&query=${feature.center[1]},${feature.center[0]}`,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erro ao buscar localização no Mapbox:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca reversa: converte coordenadas em endereço
|
||||
* Retorna o endereço completo baseado nas coordenadas do pin
|
||||
*/
|
||||
export async function reverseGeocode(
|
||||
lat: number,
|
||||
lng: number
|
||||
): Promise<MapboxResult | null> {
|
||||
if (!MAPBOX_TOKEN || MAPBOX_TOKEN.startsWith("YOUR_")) {
|
||||
console.warn("⚠️ Mapbox Token não configurado");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const url =
|
||||
`https://api.mapbox.com/geocoding/v5/mapbox.places/${lng},${lat}.json?` +
|
||||
`access_token=${MAPBOX_TOKEN}&` +
|
||||
`language=pt&` +
|
||||
`types=address,place`;
|
||||
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.features && data.features.length > 0) {
|
||||
const feature = data.features[0];
|
||||
const context = feature.context || [];
|
||||
const place = context.find((c: any) => c.id.startsWith("place"));
|
||||
const region = context.find((c: any) => c.id.startsWith("region"));
|
||||
const postcode = context.find((c: any) => c.id.startsWith("postcode"));
|
||||
|
||||
// Extrair número se houver
|
||||
const addressMatch = feature.address || "";
|
||||
|
||||
return {
|
||||
description: feature.place_name,
|
||||
street: feature.text,
|
||||
number: addressMatch.toString(),
|
||||
city: place?.text || "",
|
||||
state: region?.short_code?.replace("BR-", "") || region?.text || "",
|
||||
zip: postcode?.text || "",
|
||||
lat,
|
||||
lng,
|
||||
mapLink: `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Erro no reverse geocode:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +1,25 @@
|
|||
|
||||
export enum UserRole {
|
||||
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
|
|
@ -0,0 +1,9 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_MAPBOX_TOKEN: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
59
index.html
|
|
@ -1,59 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PhotumManager - Gestão de Eventos</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Playfair+Display:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet">
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'sans-serif'],
|
||||
serif: ['Playfair Display', 'serif'],
|
||||
},
|
||||
colors: {
|
||||
brand: {
|
||||
black: '#1a1a1a',
|
||||
gold: '#B9CF33', // Updated from #c5a059 to #B9CF33
|
||||
gray: '#f4f4f4',
|
||||
darkgray: '#333333'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
/* Smooth scrolling */
|
||||
html { scroll-behavior: smooth; }
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar { width: 8px; }
|
||||
::-webkit-scrollbar-track { background: #f1f1f1; }
|
||||
::-webkit-scrollbar-thumb { background: #B9CF33; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #a5bd2e; }
|
||||
|
||||
.fade-in { animation: fadeIn 0.5s ease-out forwards; }
|
||||
.slide-up { animation: slideUp 0.6s ease-out forwards; }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
|
||||
</style>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"react/": "https://aistudiocdn.com/react@^19.2.0/",
|
||||
"react": "https://aistudiocdn.com/react@^19.2.0",
|
||||
"lucide-react": "https://aistudiocdn.com/lucide-react@^0.554.0",
|
||||
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/",
|
||||
"@google/genai": "https://aistudiocdn.com/@google/genai@^1.30.0"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-white text-brand-black antialiased selection:bg-brand-gold selection:text-white">
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
15
index.tsx
|
|
@ -1,15 +0,0 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error("Could not find root element to mount to");
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
|
@ -1,465 +0,0 @@
|
|||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { UserRole, EventData, EventStatus, EventType } from '../types';
|
||||
import { EventCard } from '../components/EventCard';
|
||||
import { EventForm } from '../components/EventForm';
|
||||
import { Button } from '../components/Button';
|
||||
import { PlusCircle, Search, CheckCircle, Clock, Upload, Edit, Users, Map, Image as ImageIcon } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useData } from '../contexts/DataContext';
|
||||
import { STATUS_COLORS } from '../constants';
|
||||
|
||||
interface DashboardProps {
|
||||
initialView?: 'list' | 'create' | 'uploads';
|
||||
}
|
||||
|
||||
export const Dashboard: React.FC<DashboardProps> = ({ initialView = 'list' }) => {
|
||||
const { user } = useAuth();
|
||||
const { events, getEventsByRole, addEvent, updateEventStatus, assignPhotographer, addAttachment } = useData();
|
||||
const [view, setView] = useState<'list' | 'create' | 'edit' | 'details' | 'uploads'>(initialView);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedEvent, setSelectedEvent] = useState<EventData | null>(null);
|
||||
const [activeFilter, setActiveFilter] = useState<string>('all');
|
||||
|
||||
// Reset view when initialView prop changes
|
||||
useEffect(() => {
|
||||
if (initialView) {
|
||||
setView(initialView);
|
||||
if (initialView === 'create') setSelectedEvent(null);
|
||||
}
|
||||
}, [initialView]);
|
||||
|
||||
// Guard Clause for basic security
|
||||
if (!user) return <div className="p-10 text-center">Acesso Negado. Faça login.</div>;
|
||||
|
||||
const myEvents = getEventsByRole(user.id, user.role);
|
||||
|
||||
// Filter Logic
|
||||
const filteredEvents = myEvents.filter(e => {
|
||||
const matchesSearch = e.name.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesStatus = activeFilter === 'all' ||
|
||||
(activeFilter === 'pending' && e.status === EventStatus.PENDING_APPROVAL) ||
|
||||
(activeFilter === 'active' && e.status !== EventStatus.ARCHIVED && e.status !== EventStatus.PENDING_APPROVAL);
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
const handleSaveEvent = (data: any) => {
|
||||
const isClient = user.role === UserRole.EVENT_OWNER;
|
||||
|
||||
if (view === 'edit' && selectedEvent) {
|
||||
const updatedEvent = { ...selectedEvent, ...data };
|
||||
console.log("Updated", updatedEvent);
|
||||
setSelectedEvent(updatedEvent);
|
||||
setView('details');
|
||||
} else {
|
||||
const initialStatus = isClient ? EventStatus.PENDING_APPROVAL : EventStatus.PLANNING;
|
||||
const newEvent: EventData = {
|
||||
...data,
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
status: initialStatus,
|
||||
checklist: [],
|
||||
attachments: [],
|
||||
ownerId: isClient ? user.id : 'unknown',
|
||||
photographerIds: []
|
||||
};
|
||||
addEvent(newEvent);
|
||||
setView('list');
|
||||
}
|
||||
};
|
||||
|
||||
const handleApprove = (e: React.MouseEvent, eventId: string) => {
|
||||
e.stopPropagation();
|
||||
updateEventStatus(eventId, EventStatus.CONFIRMED);
|
||||
};
|
||||
|
||||
const handleOpenMaps = () => {
|
||||
if (!selectedEvent) return;
|
||||
if (selectedEvent.address.mapLink) {
|
||||
window.open(selectedEvent.address.mapLink, '_blank');
|
||||
return;
|
||||
}
|
||||
const { street, number, city, state } = selectedEvent.address;
|
||||
const query = encodeURIComponent(`${street}, ${number}, ${city} - ${state}`);
|
||||
window.open(`https://www.google.com/maps/search/?api=1&query=${query}`, '_blank');
|
||||
};
|
||||
|
||||
const handleManageTeam = () => {
|
||||
if (!selectedEvent) return;
|
||||
const newId = window.prompt("ID do Fotógrafo para adicionar (ex: photographer-1):", "photographer-1");
|
||||
if (newId) {
|
||||
assignPhotographer(selectedEvent.id, newId);
|
||||
alert("Fotógrafo atribuído com sucesso!");
|
||||
const updated = events.find(e => e.id === selectedEvent.id);
|
||||
if (updated) setSelectedEvent(updated);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadPhoto = () => {
|
||||
if (!selectedEvent) return;
|
||||
// Mock Upload Action
|
||||
const newPhoto = {
|
||||
name: `Foto_${Date.now()}.jpg`,
|
||||
size: '3.5MB',
|
||||
type: 'image/jpeg',
|
||||
url: `https://picsum.photos/id/${Math.floor(Math.random() * 100)}/400/400`
|
||||
};
|
||||
addAttachment(selectedEvent.id, newPhoto);
|
||||
// Force refresh of selectedEvent state from context source
|
||||
const updated = events.find(e => e.id === selectedEvent.id);
|
||||
if (updated) {
|
||||
// manually inject the new attachment for immediate UI feedback if context isn't enough
|
||||
setSelectedEvent({...updated, attachments: [...updated.attachments, newPhoto]});
|
||||
}
|
||||
};
|
||||
|
||||
// --- RENDERS PER ROLE ---
|
||||
|
||||
const renderRoleSpecificHeader = () => {
|
||||
if (user.role === UserRole.EVENT_OWNER) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-3xl font-serif font-bold text-brand-black">Meus Eventos</h1>
|
||||
<p className="text-gray-500 mt-1">Acompanhe seus eventos ou solicite novos orçamentos.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (user.role === UserRole.PHOTOGRAPHER) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-3xl font-serif font-bold text-brand-black">Eventos Designados</h1>
|
||||
<p className="text-gray-500 mt-1">Gerencie seus trabalhos e realize uploads.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-3xl font-serif font-bold text-brand-black">Gestão Geral</h1>
|
||||
<p className="text-gray-500 mt-1">Controle total de eventos, aprovações e equipes.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderRoleSpecificActions = () => {
|
||||
if (user.role === UserRole.PHOTOGRAPHER) return null;
|
||||
|
||||
const label = user.role === UserRole.EVENT_OWNER ? "Solicitar Novo Evento" : "Novo Evento";
|
||||
|
||||
return (
|
||||
<Button onClick={() => setView('create')} className="shadow-lg">
|
||||
<PlusCircle className="mr-2 h-5 w-5" /> {label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const renderAdminActions = (event: EventData) => {
|
||||
if (user.role !== UserRole.BUSINESS_OWNER && user.role !== UserRole.SUPERADMIN) return null;
|
||||
|
||||
if (event.status === EventStatus.PENDING_APPROVAL) {
|
||||
return (
|
||||
<div className="absolute top-3 left-3 flex space-x-2 z-10">
|
||||
<button
|
||||
onClick={(e) => handleApprove(e, event.id)}
|
||||
className="bg-green-500 text-white px-3 py-1 rounded-sm text-xs font-bold shadow hover:bg-green-600 transition-colors flex items-center"
|
||||
>
|
||||
<CheckCircle size={12} className="mr-1" /> APROVAR
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// --- MAIN RENDER ---
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white pt-24 pb-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
|
||||
{/* Header */}
|
||||
{view === 'list' && (
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between mb-8 gap-4 fade-in">
|
||||
{renderRoleSpecificHeader()}
|
||||
{renderRoleSpecificActions()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Switcher */}
|
||||
{view === 'list' && (
|
||||
<div className="space-y-6 fade-in">
|
||||
{/* Filters Bar */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-center justify-between bg-gray-50 p-3 rounded-lg border border-gray-100">
|
||||
<div className="relative flex-1 w-full">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar evento..."
|
||||
className="w-full pl-10 pr-4 py-2 bg-white border border-gray-200 rounded-sm focus:outline-none focus:border-brand-gold text-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(user.role === UserRole.BUSINESS_OWNER || user.role === UserRole.SUPERADMIN) && (
|
||||
<div className="flex space-x-2 bg-white p-1 rounded border border-gray-200">
|
||||
<button
|
||||
onClick={() => setActiveFilter('all')}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-sm ${activeFilter === 'all' ? 'bg-brand-black text-white' : 'text-gray-600 hover:bg-gray-100'}`}
|
||||
>
|
||||
Todos
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveFilter('pending')}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-sm flex items-center ${activeFilter === 'pending' ? 'bg-brand-gold text-white' : 'text-gray-600 hover:bg-gray-100'}`}
|
||||
>
|
||||
<Clock size={12} className="mr-1"/> Pendentes
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{filteredEvents.map(event => (
|
||||
<div key={event.id} className="relative group">
|
||||
{renderAdminActions(event)}
|
||||
<EventCard
|
||||
event={event}
|
||||
onClick={() => { setSelectedEvent(event); setView('details'); }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredEvents.length === 0 && (
|
||||
<div className="text-center py-20 bg-gray-50 rounded-lg border border-dashed border-gray-200">
|
||||
<p className="text-gray-500 mb-4">Nenhum evento encontrado com os filtros atuais.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(view === 'create' || view === 'edit') && (
|
||||
<EventForm
|
||||
onCancel={() => setView(view === 'edit' ? 'details' : 'list')}
|
||||
onSubmit={handleSaveEvent}
|
||||
initialData={view === 'edit' ? selectedEvent : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === 'details' && selectedEvent && (
|
||||
<div className="fade-in">
|
||||
<Button variant="ghost" onClick={() => setView('list')} className="mb-4 pl-0">
|
||||
← Voltar para lista
|
||||
</Button>
|
||||
|
||||
{/* Status Banner */}
|
||||
{selectedEvent.status === EventStatus.PENDING_APPROVAL && user.role === UserRole.EVENT_OWNER && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 text-yellow-800 p-4 rounded-lg mb-6 flex items-start">
|
||||
<Clock className="mr-3 flex-shrink-0" />
|
||||
<div>
|
||||
<h4 className="font-bold">Solicitação em Análise</h4>
|
||||
<p className="text-sm mt-1">Seu evento foi enviado e está aguardando aprovação da equipe Photum.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white border rounded-lg overflow-hidden shadow-sm">
|
||||
<div className="h-64 w-full relative">
|
||||
<img src={selectedEvent.coverImage} className="w-full h-full object-cover" alt="Cover" />
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
||||
<h1 className="text-4xl font-serif text-white font-bold text-center px-4 drop-shadow-lg">{selectedEvent.name}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div className="col-span-2 space-y-8">
|
||||
{/* Actions Toolbar */}
|
||||
<div className="flex flex-wrap gap-3 border-b pb-4">
|
||||
{user.role === UserRole.PHOTOGRAPHER && (
|
||||
<Button onClick={() => setView('uploads')} className="flex items-center">
|
||||
<Upload size={16} className="mr-2" /> Gerenciar Uploads
|
||||
</Button>
|
||||
)}
|
||||
{(user.role === UserRole.BUSINESS_OWNER || user.role === UserRole.SUPERADMIN) && (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setView('edit')}>
|
||||
<Edit size={16} className="mr-2"/> Editar Detalhes
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleManageTeam}>
|
||||
<Users size={16} className="mr-2"/> Gerenciar Equipe
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{user.role === UserRole.EVENT_OWNER && selectedEvent.status !== EventStatus.ARCHIVED && (
|
||||
<Button variant="outline" onClick={() => setView('edit')}>
|
||||
<Edit size={16} className="mr-2"/> Editar Informações
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h3 className="text-lg font-bold border-b pb-2 mb-4 text-brand-black">Sobre o Evento</h3>
|
||||
<p className="text-gray-600 leading-relaxed whitespace-pre-wrap">{selectedEvent.briefing || "Sem briefing detalhado."}</p>
|
||||
</section>
|
||||
|
||||
{selectedEvent.contacts.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-lg font-bold border-b pb-2 mb-4 text-brand-black">Contatos & Responsáveis</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{selectedEvent.contacts.map((c, i) => (
|
||||
<div key={i} className="bg-gray-50 p-4 rounded-sm border border-gray-100">
|
||||
<p className="font-bold text-sm">{c.name}</p>
|
||||
<p className="text-xs text-brand-gold uppercase tracking-wide">{c.role}</p>
|
||||
<p className="text-sm text-gray-500 mt-1">{c.phone}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-span-1 space-y-6">
|
||||
<div className={`p-6 rounded-sm border ${STATUS_COLORS[selectedEvent.status]} bg-opacity-10`}>
|
||||
<h4 className="font-bold uppercase tracking-widest text-xs mb-2 opacity-70">Status Atual</h4>
|
||||
<p className="text-xl font-serif font-bold">{selectedEvent.status}</p>
|
||||
</div>
|
||||
|
||||
<div className="border p-6 rounded-sm bg-gray-50">
|
||||
<h4 className="font-bold uppercase tracking-widest text-xs mb-4 text-gray-400">Localização</h4>
|
||||
<p className="font-medium text-lg">{selectedEvent.address.street}, {selectedEvent.address.number}</p>
|
||||
<p className="text-gray-500 mb-4">{selectedEvent.address.city} - {selectedEvent.address.state}</p>
|
||||
|
||||
{selectedEvent.address.mapLink ? (
|
||||
<Button variant="secondary" size="sm" className="w-full" onClick={handleOpenMaps}>
|
||||
<Map size={16} className="mr-2"/> Abrir no Google Maps
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" className="w-full bg-white" onClick={handleOpenMaps}>
|
||||
<Map size={16} className="mr-2"/> Buscar no Maps
|
||||
</Button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{(selectedEvent.photographerIds.length > 0 || user.role === UserRole.BUSINESS_OWNER) && (
|
||||
<div className="border p-6 rounded-sm">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h4 className="font-bold uppercase tracking-widest text-xs text-gray-400">Equipe Designada</h4>
|
||||
{(user.role === UserRole.BUSINESS_OWNER || user.role === UserRole.SUPERADMIN) && (
|
||||
<button onClick={handleManageTeam} className="text-brand-gold hover:text-brand-black"><PlusCircle size={16}/></button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedEvent.photographerIds.length > 0 ? (
|
||||
<div className="flex -space-x-2">
|
||||
{selectedEvent.photographerIds.map((id, idx) => (
|
||||
<div key={id} className="w-10 h-10 rounded-full border-2 border-white bg-gray-300"
|
||||
style={{backgroundImage: `url(https://i.pravatar.cc/100?u=${id})`, backgroundSize: 'cover'}}
|
||||
title={id}
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400 italic">Nenhum profissional atribuído.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'uploads' && (
|
||||
<div className="fade-in">
|
||||
{/* Check if user came from 'details' of a selected event OR came from Navbar */}
|
||||
{selectedEvent ? (
|
||||
<div>
|
||||
<Button variant="ghost" onClick={() => setView('details')} className="mb-4 pl-0">
|
||||
← Voltar para Detalhes
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-serif text-brand-black">Galeria de Evento: {selectedEvent.name}</h2>
|
||||
<p className="text-gray-500 text-sm">Gerencie as fotos e faça novos uploads.</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setSelectedEvent(null)}>
|
||||
Trocar Evento
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Drag and Drop Area */}
|
||||
<div
|
||||
className="border-2 border-dashed border-gray-300 rounded-lg p-12 text-center bg-gray-50 hover:bg-gray-100 transition-colors cursor-pointer group mb-8"
|
||||
onClick={handleUploadPhoto}
|
||||
>
|
||||
<Upload size={48} className="mx-auto text-gray-400 mb-4 group-hover:text-brand-gold transition-colors" />
|
||||
<h3 className="text-xl font-medium text-gray-700 mb-2">Adicionar Novas Fotos</h3>
|
||||
<p className="text-gray-500">Clique aqui para simular o upload de uma nova imagem</p>
|
||||
</div>
|
||||
|
||||
{/* Gallery Grid */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-bold text-lg flex items-center">
|
||||
<ImageIcon className="mr-2 text-brand-gold" size={20}/>
|
||||
Fotos do Evento ({selectedEvent.attachments.filter(a => a.type.startsWith('image')).length})
|
||||
</h3>
|
||||
|
||||
{selectedEvent.attachments.length > 0 ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{selectedEvent.attachments.map((file, idx) => (
|
||||
<div key={idx} className="relative group aspect-square bg-gray-100 rounded overflow-hidden shadow-sm hover:shadow-md transition-all">
|
||||
{file.url ? (
|
||||
<img src={file.url} alt={file.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400">
|
||||
<ImageIcon size={32}/>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-end p-2">
|
||||
<span className="text-white text-xs truncate w-full">{file.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-10 bg-white border rounded">
|
||||
<p className="text-gray-400">Nenhuma foto carregada ainda.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Logic when clicking "Meus Uploads" in navbar: Select an Event first
|
||||
<div>
|
||||
<h2 className="text-2xl font-serif text-brand-black mb-6">Selecione um evento para gerenciar uploads</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{myEvents.map(event => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="bg-white border hover:border-brand-gold rounded-lg p-6 cursor-pointer hover:shadow-lg transition-all"
|
||||
onClick={() => setSelectedEvent(event)}
|
||||
>
|
||||
<h3 className="font-bold text-lg mb-2">{event.name}</h3>
|
||||
<p className="text-gray-500 text-sm mb-4">{new Date(event.date).toLocaleDateString()}</p>
|
||||
<div className="flex items-center text-brand-gold text-sm font-medium">
|
||||
<ImageIcon size={16} className="mr-2"/>
|
||||
{event.attachments.length} arquivos
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{myEvents.length === 0 && (
|
||||
<p className="text-gray-500 col-span-3 text-center py-10">Você não possui eventos designados no momento.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
112
pages/Home.tsx
|
|
@ -1,112 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Button } from '../components/Button';
|
||||
import { Camera, Heart, Shield, Star } from 'lucide-react';
|
||||
|
||||
const HERO_IMAGES = [
|
||||
"https://images.unsplash.com/photo-1511285560982-1351cdeb9821?ixlib=rb-1.2.1&auto=format&fit=crop&w=1920&q=80",
|
||||
"https://images.unsplash.com/photo-1519741497674-611481863552?ixlib=rb-1.2.1&auto=format&fit=crop&w=1920&q=80",
|
||||
"https://images.unsplash.com/photo-1472653431158-6364773b2710?ixlib=rb-1.2.1&auto=format&fit=crop&w=1920&q=80"
|
||||
];
|
||||
|
||||
interface HomeProps {
|
||||
onEnter: () => void;
|
||||
}
|
||||
|
||||
export const Home: React.FC<HomeProps> = ({ onEnter }) => {
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setCurrentSlide((prev) => (prev + 1) % HERO_IMAGES.length);
|
||||
}, 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Hero Section */}
|
||||
<div className="relative h-screen w-full overflow-hidden">
|
||||
{HERO_IMAGES.map((img, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`absolute inset-0 transition-opacity duration-1000 ease-in-out ${idx === currentSlide ? 'opacity-100' : 'opacity-0'}`}
|
||||
>
|
||||
<img src={img} alt="Hero" className="w-full h-full object-cover" />
|
||||
<div className="absolute inset-0 bg-black/40"></div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="absolute inset-0 flex items-center justify-center text-center px-4">
|
||||
<div className="max-w-4xl space-y-6 slide-up">
|
||||
<h1 className="text-4xl md:text-6xl lg:text-7xl font-serif text-white leading-tight">
|
||||
Eternizando Momentos <br />
|
||||
<span className="text-brand-gold italic">Únicos</span>
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl text-gray-200 font-light max-w-2xl mx-auto tracking-wide">
|
||||
Gestão completa para eventos inesquecíveis. Do planejamento à entrega do álbum perfeito.
|
||||
</p>
|
||||
<div className="pt-8 space-x-4">
|
||||
<Button size="lg" variant="secondary" onClick={onEnter}>
|
||||
Área do Cliente
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" className="border-white text-white hover:bg-white hover:text-black">
|
||||
Ver Portfólio
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Carousel Dots */}
|
||||
<div className="absolute bottom-10 left-0 right-0 flex justify-center space-x-3">
|
||||
{HERO_IMAGES.map((_, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
className={`w-2 h-2 rounded-full transition-all ${idx === currentSlide ? 'bg-brand-gold w-8' : 'bg-white/50'}`}
|
||||
onClick={() => setCurrentSlide(idx)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="py-20 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-sm font-bold tracking-widest text-brand-gold uppercase mb-2">Por que nós?</h2>
|
||||
<h3 className="text-3xl md:text-4xl font-serif text-brand-black">Excelência em cada detalhe</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-12">
|
||||
{[
|
||||
{ icon: <Camera size={32}/>, title: "Qualidade Impecável", desc: "Equipamentos de última geração e profissionais premiados." },
|
||||
{ icon: <Shield size={32}/>, title: "Segurança Total", desc: "Backup duplo em nuvem e contratos transparentes." },
|
||||
{ icon: <Heart size={32}/>, title: "Atendimento Humanizado", desc: "Entendemos que seu evento é um sonho a ser realizado." }
|
||||
].map((feature, idx) => (
|
||||
<div key={idx} className="text-center group p-6 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gray-100 text-brand-black mb-6 group-hover:bg-brand-gold group-hover:text-white transition-colors">
|
||||
{feature.icon}
|
||||
</div>
|
||||
<h4 className="text-xl font-medium mb-3">{feature.title}</h4>
|
||||
<p className="text-gray-500 font-light leading-relaxed">{feature.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Testimonials */}
|
||||
<section className="py-20 bg-brand-black text-white">
|
||||
<div className="max-w-4xl mx-auto px-4 text-center">
|
||||
<Star className="text-brand-gold mx-auto mb-6" size={40} fill="#c5a059" />
|
||||
<blockquote className="text-2xl md:text-3xl font-serif italic leading-relaxed mb-8">
|
||||
"A equipe do Photum superou todas as expectativas. O sistema de acompanhamento nos deixou tranquilos durante todo o processo e as fotos ficaram incríveis."
|
||||
</blockquote>
|
||||
<cite className="not-italic">
|
||||
<span className="font-bold block text-brand-gold">Mariana & Pedro</span>
|
||||
<span className="text-sm text-gray-400 uppercase tracking-widest">Casamento em Campos do Jordão</span>
|
||||
</cite>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
111
pages/Login.tsx
|
|
@ -1,111 +0,0 @@
|
|||
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Button } from '../components/Button';
|
||||
import { Input } from '../components/Input';
|
||||
import { UserRole } from '../types';
|
||||
|
||||
export const Login: React.FC = () => {
|
||||
const { login, availableUsers } = useAuth();
|
||||
const [email, setEmail] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
const success = await login(email);
|
||||
if (!success) {
|
||||
setError('Usuário não encontrado. Tente um dos e-mails de demonstração.');
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const fillCredentials = (userEmail: string) => {
|
||||
setEmail(userEmail);
|
||||
};
|
||||
|
||||
const getRoleLabel = (role: UserRole) => {
|
||||
switch(role) {
|
||||
case UserRole.SUPERADMIN: return "Superadmin";
|
||||
case UserRole.BUSINESS_OWNER: return "Empresa";
|
||||
case UserRole.PHOTOGRAPHER: return "Fotógrafo";
|
||||
case UserRole.EVENT_OWNER: return "Cliente";
|
||||
default: return role;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex bg-white">
|
||||
{/* Left Side - Image */}
|
||||
<div className="hidden lg:block lg:w-1/2 relative overflow-hidden">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1519741497674-611481863552?ixlib=rb-1.2.1&auto=format&fit=crop&w=1920&q=80"
|
||||
alt="Photum Login"
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-brand-black/40 flex items-center justify-center">
|
||||
<div className="text-center text-white p-12">
|
||||
<h1 className="text-5xl font-serif font-bold mb-4">Photum Manager</h1>
|
||||
<p className="text-xl font-light tracking-wide max-w-md mx-auto">Gestão de eventos premium para quem não abre mão da excelência.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side - Form */}
|
||||
<div className="w-full lg:w-1/2 flex items-center justify-center p-8 lg:p-16">
|
||||
<div className="max-w-md w-full space-y-8 fade-in">
|
||||
<div className="text-center lg:text-left">
|
||||
<span className="text-brand-gold font-bold tracking-widest uppercase text-sm">Bem-vindo de volta</span>
|
||||
<h2 className="mt-2 text-3xl font-serif font-bold text-gray-900">Acesse sua conta</h2>
|
||||
</div>
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleLogin}>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="E-mail Corporativo ou Pessoal"
|
||||
type="email"
|
||||
required
|
||||
placeholder="nome@exemplo.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
error={error}
|
||||
/>
|
||||
<Input
|
||||
label="Senha"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
readOnly
|
||||
className="bg-gray-50 cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" size="lg" isLoading={isLoading}>
|
||||
Entrar no Sistema
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Demo Users Quick Select */}
|
||||
<div className="mt-10 pt-10 border-t border-gray-100">
|
||||
<p className="text-xs text-gray-400 uppercase tracking-widest mb-4 text-center">Usuários de Demonstração (Clique para preencher)</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{availableUsers.map(user => (
|
||||
<button
|
||||
key={user.id}
|
||||
onClick={() => fillCredentials(user.email)}
|
||||
className="flex flex-col items-start p-3 border border-gray-200 rounded-sm hover:border-brand-gold hover:bg-gray-50 transition-all text-left group"
|
||||
>
|
||||
<span className="text-xs font-bold text-gray-700 group-hover:text-brand-black">{user.name}</span>
|
||||
<span className="text-[10px] uppercase tracking-wide text-brand-gold mt-1">{getRoleLabel(user.role)}</span>
|
||||
<span className="text-[10px] text-gray-400 mt-0.5 truncate w-full">{user.email}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
import { GoogleGenAI } from "@google/genai";
|
||||
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
|
||||
|
||||
export interface GeoResult {
|
||||
street: string;
|
||||
number: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zip: string;
|
||||
description: string;
|
||||
mapLink?: string;
|
||||
}
|
||||
|
||||
export const searchLocationWithGemini = async (query: string): Promise<GeoResult[]> => {
|
||||
if (!query || query.length < 3) return [];
|
||||
|
||||
try {
|
||||
// Attempt to get user location for better context
|
||||
let userLocation: { latitude: number; longitude: number } | undefined;
|
||||
try {
|
||||
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 2000 });
|
||||
});
|
||||
userLocation = {
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude
|
||||
};
|
||||
} catch (e) {
|
||||
// Ignore geolocation errors, proceed without it
|
||||
}
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: 'gemini-2.5-flash',
|
||||
contents: `Find the specific address details for the location query: "${query}".
|
||||
|
||||
You MUST return the address components in the following format (one per line):
|
||||
Description: [A concise formatted address]
|
||||
Street: [Street Name]
|
||||
Number: [Street Number, if available]
|
||||
City: [City Name]
|
||||
State: [State Code, e.g., SP, RJ]
|
||||
Zip: [Postal Code]
|
||||
|
||||
If a specific component is not found, leave it empty after the colon.`,
|
||||
config: {
|
||||
tools: [{ googleMaps: {} }],
|
||||
toolConfig: userLocation ? {
|
||||
retrievalConfig: {
|
||||
latLng: {
|
||||
latitude: userLocation.latitude,
|
||||
longitude: userLocation.longitude
|
||||
}
|
||||
}
|
||||
} : undefined
|
||||
}
|
||||
});
|
||||
|
||||
const text = response.text || "";
|
||||
|
||||
// Helper parser
|
||||
const parseField = (key: string) => {
|
||||
const regex = new RegExp(`${key}:\\s*(.*)`, 'i');
|
||||
const match = text.match(regex);
|
||||
return match ? match[1].trim() : '';
|
||||
};
|
||||
|
||||
// Extract Google Maps URI from grounding metadata
|
||||
let mapUri = '';
|
||||
const chunks = response.candidates?.[0]?.groundingMetadata?.groundingChunks || [];
|
||||
|
||||
// Look for the Web URI in the chunks (Google Maps grounding often returns this)
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.web?.uri && (chunk.web.uri.includes('google.com/maps') || chunk.web.uri.includes('maps.google.com'))) {
|
||||
mapUri = chunk.web.uri;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const result: GeoResult = {
|
||||
street: parseField('Street'),
|
||||
number: parseField('Number'),
|
||||
city: parseField('City'),
|
||||
state: parseField('State'),
|
||||
zip: parseField('Zip'),
|
||||
description: parseField('Description') || text.split('\n')[0], // Fallback to first line
|
||||
mapLink: mapUri
|
||||
};
|
||||
|
||||
// Filter out bad results (empty city/state usually means it failed to identify a place)
|
||||
if (!result.city && !result.street) return [];
|
||||
|
||||
return [result];
|
||||
|
||||
} catch (error) {
|
||||
console.error("Gemini Search Error:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||