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:
parent
0588a2da1d
commit
1caeddc72c
23 changed files with 2214 additions and 8 deletions
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
114
App.tsx
Normal 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>© 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;
|
||||
25
README.md
25
README.md
|
|
@ -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
47
components/Button.tsx
Normal 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
100
components/EventCard.tsx
Normal 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
405
components/EventForm.tsx
Normal 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
51
components/Input.tsx
Normal 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
184
components/Navbar.tsx
Normal 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
15
constants.ts
Normal 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
76
contexts/AuthContext.tsx
Normal 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
123
contexts/DataContext.tsx
Normal 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
59
index.html
Normal 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
15
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 to mount to");
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
7
metadata.json
Normal file
7
metadata.json
Normal 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
23
package.json
Normal 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
465
pages/Dashboard.tsx
Normal 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
112
pages/Home.tsx
Normal 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
111
pages/Login.tsx
Normal 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
99
services/genaiService.ts
Normal 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 [];
|
||||
}
|
||||
};
|
||||
33
services/mockGeoService.ts
Normal file
33
services/mockGeoService.ts
Normal 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
29
tsconfig.json
Normal 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
82
types.ts
Normal 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
23
vite.config.ts
Normal 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, '.'),
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Loading…
Reference in a new issue