feat: Initialize PhotumManager project structure

This commit sets up the foundational project structure for PhotumManager. It includes:
- Initializing a new React project with Vite.
- Configuring essential dependencies such as React, Lucide React, and the Google Generative AI SDK.
- Setting up TypeScript and Vite configurations for optimal development.
- Defining core application metadata and initial type definitions for users and events.
- Establishing basic styling and font configurations in `index.html` with Tailwind CSS.
- Adding a `.gitignore` file to manage project dependencies and build artifacts.
- Updating the README with instructions for local development.
This commit is contained in:
João Vitor 2025-11-25 11:02:25 -03:00
parent 0588a2da1d
commit 1caeddc72c
23 changed files with 2214 additions and 8 deletions

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

114
App.tsx Normal file
View file

@ -0,0 +1,114 @@
import React, { useState, useEffect } from 'react';
import { Navbar } from './components/Navbar';
import { Home } from './pages/Home';
import { Dashboard } from './pages/Dashboard';
import { Login } from './pages/Login';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { DataProvider } from './contexts/DataContext';
import { Construction } from 'lucide-react'; // Placeholder icon
const AppContent: React.FC = () => {
const { user } = useAuth();
const [currentPage, setCurrentPage] = useState('home');
useEffect(() => {
if (user && currentPage === 'login') {
setCurrentPage('dashboard');
}
}, [user, currentPage]);
// Simple Router Logic
const renderPage = () => {
if (currentPage === 'home') return <Home onEnter={() => setCurrentPage(user ? 'dashboard' : 'login')} />;
if (currentPage === 'login') return user ? <Dashboard /> : <Login />;
// Protected Routes Check
if (!user) return <Login />;
switch (currentPage) {
case 'dashboard':
case 'events':
return <Dashboard initialView="list" />;
case 'request-event':
return <Dashboard initialView="create" />;
case 'uploads':
return <Dashboard initialView="uploads" />;
// Placeholder routes for future implementation
case 'team':
case 'finance':
case 'settings':
case 'albums':
case 'calendar':
return (
<div className="min-h-screen bg-white pt-32 px-4 text-center fade-in">
<div className="max-w-md mx-auto bg-gray-50 p-12 rounded-lg border border-gray-100 shadow-sm">
<div className="mx-auto w-16 h-16 bg-gray-200 rounded-full flex items-center justify-center mb-6 text-gray-400">
<Construction size={32} />
</div>
<h2 className="text-2xl font-serif font-bold mb-3 text-brand-black capitalize">
{currentPage === 'team' ? 'Equipe & Fotógrafos' :
currentPage === 'finance' ? 'Financeiro' :
currentPage === 'calendar' ? 'Agenda' :
currentPage}
</h2>
<p className="text-gray-500 mb-8 leading-relaxed">
Esta funcionalidade está em desenvolvimento e estará disponível em breve no seu painel.
</p>
<button
onClick={() => setCurrentPage('dashboard')}
className="px-6 py-2 bg-brand-black text-white rounded-sm hover:bg-gray-800 transition-colors font-medium text-sm"
>
Voltar ao Dashboard
</button>
</div>
</div>
);
default:
// Fallback
return <Dashboard initialView="list" />;
}
};
return (
<div className="min-h-screen bg-white">
<Navbar
onNavigate={setCurrentPage}
currentPage={currentPage}
/>
<main>
{renderPage()}
</main>
{/* Footer only on Home */}
{currentPage === 'home' && (
<footer className="bg-white border-t border-gray-100 py-12">
<div className="max-w-7xl mx-auto px-4 flex flex-col md:flex-row justify-between items-center text-sm text-gray-500">
<p>&copy; 2024 PhotumManager. Todos os direitos reservados.</p>
<div className="flex space-x-6 mt-4 md:mt-0">
<a href="#" className="hover:text-brand-black">Política de Privacidade</a>
<a href="#" className="hover:text-brand-black">Termos de Uso</a>
<a href="#" className="hover:text-brand-black">Instagram</a>
</div>
</div>
</footer>
)}
</div>
);
};
function App() {
return (
<AuthProvider>
<DataProvider>
<AppContent />
</DataProvider>
</AuthProvider>
);
}
export default App;

View file

@ -1,11 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
<h1>Built with AI Studio</h2>
<p>The fastest path from prompt to production with Gemini.</p>
<a href="https://aistudio.google.com/apps">Start building</a>
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/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`

47
components/Button.tsx Normal file
View file

@ -0,0 +1,47 @@
import React from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
}
export const Button: React.FC<ButtonProps> = ({
children,
variant = 'primary',
size = 'md',
isLoading,
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",
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"
};
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"
};
return (
<button
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
disabled={isLoading || props.disabled}
{...props}
>
{isLoading ? (
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-current" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : null}
{children}
</button>
);
};

100
components/EventCard.tsx Normal file
View file

@ -0,0 +1,100 @@
import React, { useState } from 'react';
import { EventData, EventStatus } from '../types';
import { Calendar, MapPin, ArrowRight, UserCheck } from 'lucide-react';
import { STATUS_COLORS } from '../constants';
interface EventCardProps {
event: EventData;
onClick: () => void;
}
export const EventCard: React.FC<EventCardProps> = ({ event, onClick }) => {
const [imageLoaded, setImageLoaded] = useState(false);
const fullAddress = `${event.address.street}, ${event.address.number} - ${event.address.city}/${event.address.state}`;
return (
<div
className="group bg-white rounded-lg border border-gray-100 overflow-hidden hover:shadow-xl transition-all duration-300 cursor-pointer flex flex-col h-full"
onClick={onClick}
>
<div className="relative h-48 overflow-hidden bg-gray-100">
{/* Skeleton / Loading State */}
{!imageLoaded && (
<div className="absolute inset-0 bg-gray-200 animate-pulse flex items-center justify-center">
<div className="w-8 h-8 border-2 border-gray-300 border-t-brand-gold rounded-full animate-spin"></div>
</div>
)}
<img
src={event.coverImage}
alt={event.name}
className={`w-full h-full object-cover transition-all duration-700 group-hover:scale-105 ${imageLoaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setImageLoaded(true)}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-60"></div>
<div className="absolute top-3 right-3">
<span className={`text-xs font-semibold px-2 py-1 rounded-sm uppercase tracking-wide ${STATUS_COLORS[event.status]}`}>
{event.status}
</span>
</div>
<div className="absolute bottom-3 left-4 text-white">
<p className="text-xs font-light uppercase tracking-widest opacity-90">{event.type}</p>
<h3 className="text-lg font-serif font-medium drop-shadow-md">{event.name}</h3>
</div>
</div>
<div className="p-5 flex-1 flex flex-col justify-between">
<div className="space-y-3">
<div className="flex items-center text-gray-500 text-sm">
<Calendar size={16} className="mr-2 text-brand-gold" />
<span>{new Date(event.date).toLocaleDateString()} às {event.time}</span>
</div>
{/* Location with Tooltip */}
<div className="relative group/tooltip">
<div
className="flex items-center text-gray-500 text-sm"
title={fullAddress} // Native tooltip fallback
>
<MapPin size={16} className="mr-2 text-brand-gold flex-shrink-0" />
<span className="truncate">{event.address.city}, {event.address.state}</span>
</div>
{/* Custom Tooltip */}
<div className="absolute bottom-full left-0 mb-2 hidden group-hover/tooltip:block z-20 pointer-events-none">
<div className="bg-brand-black text-white text-xs rounded py-1.5 px-3 whitespace-nowrap shadow-xl">
{event.address.street}, {event.address.number}
<br/>
{event.address.city} - {event.address.state}
{/* Arrow */}
<div className="absolute top-full left-3 -mt-1 border-4 border-transparent border-t-brand-black"></div>
</div>
</div>
</div>
{event.contacts.length > 0 && (
<div className="flex items-center text-gray-500 text-sm">
<UserCheck size={16} className="mr-2 text-brand-gold" />
<span>{event.contacts.length} {event.contacts.length === 1 ? 'Fornecedor/Contato' : 'Fornecedores/Contatos'}</span>
</div>
)}
</div>
<div className="mt-5 pt-4 border-t border-gray-50 flex items-center justify-between">
<div className="flex -space-x-2">
{[1,2,3].map(i => (
<div key={i} className="w-8 h-8 rounded-full border-2 border-white bg-gray-200" style={{backgroundImage: `url(https://i.pravatar.cc/100?img=${i})`, backgroundSize: 'cover'}}></div>
))}
<div className="w-8 h-8 rounded-full border-2 border-white bg-gray-100 flex items-center justify-center text-xs text-gray-500 font-medium">
+4
</div>
</div>
<button className="text-brand-black group-hover:text-brand-gold transition-colors">
<ArrowRight size={20} />
</button>
</div>
</div>
</div>
);
};

405
components/EventForm.tsx Normal file
View file

@ -0,0 +1,405 @@
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>
);
};

51
components/Input.tsx Normal file
View file

@ -0,0 +1,51 @@
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>
);
};

184
components/Navbar.tsx Normal file
View file

@ -0,0 +1,184 @@
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>
);
};

15
constants.ts Normal file
View file

@ -0,0 +1,15 @@
import { EventStatus, EventType } from './types';
// Mock data moved to DataContext, keeping constants for Colors/Labels
export const MOCK_EVENTS = []; // Deprecated, use DataContext
export const STATUS_COLORS: Record<EventStatus, string> = {
[EventStatus.PENDING_APPROVAL]: 'bg-yellow-50 text-yellow-700 border-yellow-200',
[EventStatus.PLANNING]: 'bg-gray-100 text-gray-800 border-gray-200',
[EventStatus.CONFIRMED]: 'bg-blue-50 text-blue-800 border-blue-100',
[EventStatus.IN_PROGRESS]: 'bg-purple-50 text-purple-800 border-purple-100',
[EventStatus.DELIVERED]: 'bg-green-50 text-green-800 border-green-100',
[EventStatus.ARCHIVED]: 'bg-gray-50 text-gray-400 border-gray-100'
};

76
contexts/AuthContext.tsx Normal file
View file

@ -0,0 +1,76 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
import { User, UserRole } from '../types';
// Mock Users Database
const MOCK_USERS: User[] = [
{
id: 'superadmin-1',
name: 'Dev Admin',
email: 'admin@photum.com',
role: UserRole.SUPERADMIN,
avatar: 'https://i.pravatar.cc/150?u=admin'
},
{
id: 'owner-1',
name: 'Carlos CEO',
email: 'empresa@photum.com',
role: UserRole.BUSINESS_OWNER,
avatar: 'https://i.pravatar.cc/150?u=ceo'
},
{
id: 'photographer-1',
name: 'Ana Lente',
email: 'foto@photum.com',
role: UserRole.PHOTOGRAPHER,
avatar: 'https://i.pravatar.cc/150?u=photo'
},
{
id: 'client-1',
name: 'Juliana Noiva',
email: 'cliente@photum.com',
role: UserRole.EVENT_OWNER,
avatar: 'https://i.pravatar.cc/150?u=client'
}
];
interface AuthContextType {
user: User | null;
login: (email: string) => Promise<boolean>;
logout: () => void;
availableUsers: User[]; // Helper for the login screen demo
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const login = async (email: string) => {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 800));
const foundUser = MOCK_USERS.find(u => u.email === email);
if (foundUser) {
setUser(foundUser);
return true;
}
return false;
};
const logout = () => {
setUser(null);
};
return (
<AuthContext.Provider value={{ user, login, logout, availableUsers: MOCK_USERS }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used within an AuthProvider');
return context;
};

123
contexts/DataContext.tsx Normal file
View file

@ -0,0 +1,123 @@
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;
};

59
index.html Normal file
View file

@ -0,0 +1,59 @@
<!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 Normal file
View file

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

7
metadata.json Normal file
View file

@ -0,0 +1,7 @@
{
"name": "PhotumManager",
"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"
]
}

23
package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "photummanager",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.0",
"lucide-react": "^0.554.0",
"react-dom": "^19.2.0",
"@google/genai": "^1.30.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

465
pages/Dashboard.tsx Normal file
View file

@ -0,0 +1,465 @@
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 Normal file
View file

@ -0,0 +1,112 @@
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 Normal file
View file

@ -0,0 +1,111 @@
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>
);
};

99
services/genaiService.ts Normal file
View file

@ -0,0 +1,99 @@
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 [];
}
};

View file

@ -0,0 +1,33 @@
// Simulates an API call to a Geocoding service
export const searchAddress = async (query: string) => {
await new Promise(resolve => setTimeout(resolve, 600)); // Network delay
if (!query) return [];
return [
{
street: query,
number: '',
city: 'São Paulo',
state: 'SP',
zip: '01000-000',
description: `${query}, São Paulo - SP, Brasil`
},
{
street: query,
number: '',
city: 'Rio de Janeiro',
state: 'RJ',
zip: '20000-000',
description: `${query}, Rio de Janeiro - RJ, Brasil`
},
{
street: query,
number: '',
city: 'Curitiba',
state: 'PR',
zip: '80000-000',
description: `${query}, Curitiba - PR, Brasil`
}
];
};

29
tsconfig.json Normal file
View file

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

82
types.ts Normal file
View file

@ -0,0 +1,82 @@
export enum UserRole {
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'
}
export enum EventType {
WEDDING = 'Casamento',
CORPORATE = 'Corporativo',
BIRTHDAY = 'Aniversário',
DEBUTANTE = 'Debutante',
OTHER = 'Outro'
}
export interface User {
id: string;
name: string;
email: string;
role: UserRole;
avatar?: string;
}
export interface Address {
street: string;
number: string;
city: string;
state: string;
zip: string;
lat?: number;
lng?: number;
mapLink?: string; // URL from Google Maps Grounding
}
export interface Contact {
id: string;
name: string;
role: string;
phone: string;
email: string;
}
export interface ChecklistItem {
id: string;
task: string;
completed: boolean;
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;
date: string;
time: string;
type: EventType;
status: EventStatus;
address: Address;
contacts: Contact[];
checklist: ChecklistItem[];
briefing: string;
coverImage: string;
attachments: Attachment[];
ownerId: string; // ID do cliente dono do evento
photographerIds: string[]; // IDs dos fotógrafos designados
}

23
vite.config.ts Normal file
View file

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