From 1caeddc72c2e05565ca0397effc69e131c268e40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Vitor?= Date: Tue, 25 Nov 2025 11:02:25 -0300 Subject: [PATCH] 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. --- .gitignore | 24 ++ App.tsx | 114 +++++++++ README.md | 25 +- components/Button.tsx | 47 ++++ components/EventCard.tsx | 100 ++++++++ components/EventForm.tsx | 405 ++++++++++++++++++++++++++++++++ components/Input.tsx | 51 ++++ components/Navbar.tsx | 184 +++++++++++++++ constants.ts | 15 ++ contexts/AuthContext.tsx | 76 ++++++ contexts/DataContext.tsx | 123 ++++++++++ index.html | 59 +++++ index.tsx | 15 ++ metadata.json | 7 + package.json | 23 ++ pages/Dashboard.tsx | 465 +++++++++++++++++++++++++++++++++++++ pages/Home.tsx | 112 +++++++++ pages/Login.tsx | 111 +++++++++ services/genaiService.ts | 99 ++++++++ services/mockGeoService.ts | 33 +++ tsconfig.json | 29 +++ types.ts | 82 +++++++ vite.config.ts | 23 ++ 23 files changed, 2214 insertions(+), 8 deletions(-) create mode 100644 .gitignore create mode 100644 App.tsx create mode 100644 components/Button.tsx create mode 100644 components/EventCard.tsx create mode 100644 components/EventForm.tsx create mode 100644 components/Input.tsx create mode 100644 components/Navbar.tsx create mode 100644 constants.ts create mode 100644 contexts/AuthContext.tsx create mode 100644 contexts/DataContext.tsx create mode 100644 index.html create mode 100644 index.tsx create mode 100644 metadata.json create mode 100644 package.json create mode 100644 pages/Dashboard.tsx create mode 100644 pages/Home.tsx create mode 100644 pages/Login.tsx create mode 100644 services/genaiService.ts create mode 100644 services/mockGeoService.ts create mode 100644 tsconfig.json create mode 100644 types.ts create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -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? diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..4cf001f --- /dev/null +++ b/App.tsx @@ -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 setCurrentPage(user ? 'dashboard' : 'login')} />; + if (currentPage === 'login') return user ? : ; + + // Protected Routes Check + if (!user) return ; + + switch (currentPage) { + case 'dashboard': + case 'events': + return ; + + case 'request-event': + return ; + + case 'uploads': + return ; + + // Placeholder routes for future implementation + case 'team': + case 'finance': + case 'settings': + case 'albums': + case 'calendar': + return ( +
+
+
+ +
+

+ {currentPage === 'team' ? 'Equipe & Fotógrafos' : + currentPage === 'finance' ? 'Financeiro' : + currentPage === 'calendar' ? 'Agenda' : + currentPage} +

+

+ Esta funcionalidade está em desenvolvimento e estará disponível em breve no seu painel. +

+ +
+
+ ); + + default: + // Fallback + return ; + } + }; + + return ( +
+ +
+ {renderPage()} +
+ + {/* Footer only on Home */} + {currentPage === 'home' && ( + + )} +
+ ); +}; + +function App() { + return ( + + + + + + ); +} + +export default App; diff --git a/README.md b/README.md index 2241000..7a7c6be 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,20 @@
- GHBanner - -

Built with AI Studio

- -

The fastest path from prompt to production with Gemini.

- - Start building -
+ +# 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` diff --git a/components/Button.tsx b/components/Button.tsx new file mode 100644 index 0000000..05e04c6 --- /dev/null +++ b/components/Button.tsx @@ -0,0 +1,47 @@ +import React from 'react'; + +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'outline' | 'ghost'; + size?: 'sm' | 'md' | 'lg'; + isLoading?: boolean; +} + +export const Button: React.FC = ({ + 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 ( + + ); +}; \ No newline at end of file diff --git a/components/EventCard.tsx b/components/EventCard.tsx new file mode 100644 index 0000000..197a582 --- /dev/null +++ b/components/EventCard.tsx @@ -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 = ({ event, onClick }) => { + const [imageLoaded, setImageLoaded] = useState(false); + const fullAddress = `${event.address.street}, ${event.address.number} - ${event.address.city}/${event.address.state}`; + + return ( +
+
+ {/* Skeleton / Loading State */} + {!imageLoaded && ( +
+
+
+ )} + + {event.name} setImageLoaded(true)} + /> +
+
+ + {event.status} + +
+
+

{event.type}

+

{event.name}

+
+
+ +
+
+
+ + {new Date(event.date).toLocaleDateString()} às {event.time} +
+ + {/* Location with Tooltip */} +
+
+ + {event.address.city}, {event.address.state} +
+ + {/* Custom Tooltip */} +
+
+ {event.address.street}, {event.address.number} +
+ {event.address.city} - {event.address.state} + {/* Arrow */} +
+
+
+
+ + {event.contacts.length > 0 && ( +
+ + {event.contacts.length} {event.contacts.length === 1 ? 'Fornecedor/Contato' : 'Fornecedores/Contatos'} +
+ )} +
+ +
+
+ {[1,2,3].map(i => ( +
+ ))} +
+ +4 +
+
+ +
+
+
+ ); +}; diff --git a/components/EventForm.tsx b/components/EventForm.tsx new file mode 100644 index 0000000..aae8dda --- /dev/null +++ b/components/EventForm.tsx @@ -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 = ({ onCancel, onSubmit, initialData }) => { + const { user } = useAuth(); + const [activeTab, setActiveTab] = useState<'details' | 'location' | 'briefing' | 'files'>('details'); + const [addressQuery, setAddressQuery] = useState(''); + const [addressResults, setAddressResults] = useState([]); + 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) => { + 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 ( +
+ + {/* Success Toast */} + {showToast && ( +
+ +
+

Sucesso!

+

As informações foram salvas.

+
+
+ )} + + {/* Form Header */} +
+
+

{formTitle}

+

+ {isClientRequest + ? "Preencha os detalhes do seu sonho. Nossa equipe analisará em breve." + : "Preencha as informações técnicas do evento."} +

+
+
+ {['details', 'location', 'briefing', 'files'].map((tab, idx) => ( +
+ + {idx + 1} + +
+ ))} +
+
+ +
+ {/* Sidebar Navigation for Form */} +
+ {[ + { 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 => ( + + ))} +
+ + {/* Form Content */} +
+ + {activeTab === 'details' && ( +
+
+ setFormData({...formData, name: e.target.value})} + /> +
+ setFormData({...formData, date: e.target.value})} + /> + setFormData({...formData, time: e.target.value})} + /> +
+ setFormData({...formData, coverImage: e.target.value})} + /> +
+ +
+ +
+
+ )} + + {activeTab === 'location' && ( +
+
+ +
+ setAddressQuery(e.target.value)} + /> +
+ {isSearching ? ( +
+ ) : ( + + )} +
+
+ + {addressResults.length > 0 && ( +
    + {addressResults.map((addr, idx) => ( +
  • handleAddressSelect(addr)} + > +
    +
    + +
    +

    {addr.description}

    +

    {addr.city}, {addr.state}

    +
    +
    + {addr.mapLink && ( + + Maps + Maps + + )} +
    +
  • + ))} +
+ )} +
+ +
+
+ +
+ setFormData({...formData, address: {...formData.address, number: e.target.value}})} + /> +
+
+ + +
+ + {formData.address.mapLink && ( +
+ + + Localização verificada via Google Maps + + + Ver no mapa + +
+ )} + +
+ + +
+
+ )} + + {activeTab === 'briefing' && ( +
+
+ + +
+ +
+
+ + +
+
+ {formData.contacts.map((contact: any, idx: number) => ( +
+ { + const newContacts = [...formData.contacts]; + newContacts[idx].name = e.target.value; + setFormData({...formData, contacts: newContacts}); + }} + /> + { + const newContacts = [...formData.contacts]; + newContacts[idx].role = e.target.value; + setFormData({...formData, contacts: newContacts}); + }} + /> + +
+ ))} +
+
+ +
+ + +
+
+ )} + + {activeTab === 'files' && ( +
+
+ + +

+ {isClientRequest ? "Anexe referências visuais (Moodboard)" : "Anexe contratos e cronogramas"} +

+

PDF, JPG, PNG (Max 10MB)

+
+ + {formData.files.length > 0 && ( +
+

Arquivos Selecionados:

+ {formData.files.map((file: any, idx: number) => ( +
+
+ +
+

{file.name}

+

{(file.size / 1024).toFixed(1)} KB

+
+
+ +
+ ))} +
+ )} + +
+ + +
+
+ )} + +
+
+
+ ); +}; \ No newline at end of file diff --git a/components/Input.tsx b/components/Input.tsx new file mode 100644 index 0000000..0a998d4 --- /dev/null +++ b/components/Input.tsx @@ -0,0 +1,51 @@ +import React from 'react'; + +interface InputProps extends React.InputHTMLAttributes { + label: string; + error?: string; +} + +export const Input: React.FC = ({ label, error, className = '', ...props }) => { + return ( +
+ + + {error && {error}} +
+ ); +}; + +interface SelectProps extends React.SelectHTMLAttributes { + label: string; + options: { value: string; label: string }[]; + error?: string; +} + +export const Select: React.FC = ({ label, options, error, className = '', ...props }) => { + return ( +
+ + + {error && {error}} +
+ ); +}; \ No newline at end of file diff --git a/components/Navbar.tsx b/components/Navbar.tsx new file mode 100644 index 0000000..bb43a27 --- /dev/null +++ b/components/Navbar.tsx @@ -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 = ({ 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 ( + + ); +}; diff --git a/constants.ts b/constants.ts new file mode 100644 index 0000000..7f18271 --- /dev/null +++ b/constants.ts @@ -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.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' +}; diff --git a/contexts/AuthContext.tsx b/contexts/AuthContext.tsx new file mode 100644 index 0000000..bc25c88 --- /dev/null +++ b/contexts/AuthContext.tsx @@ -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; + logout: () => void; + availableUsers: User[]; // Helper for the login screen demo +} + +const AuthContext = createContext(undefined); + +export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const [user, setUser] = useState(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 ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) throw new Error('useAuth must be used within an AuthProvider'); + return context; +}; diff --git a/contexts/DataContext.tsx b/contexts/DataContext.tsx new file mode 100644 index 0000000..60e003b --- /dev/null +++ b/contexts/DataContext.tsx @@ -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(undefined); + +export const DataProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const [events, setEvents] = useState(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 ( + + {children} + + ); +}; + +export const useData = () => { + const context = useContext(DataContext); + if (!context) throw new Error('useData must be used within a DataProvider'); + return context; +}; \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..251cc3d --- /dev/null +++ b/index.html @@ -0,0 +1,59 @@ + + + + + + PhotumManager - Gestão de Eventos + + + + + + + +
+ + \ No newline at end of file diff --git a/index.tsx b/index.tsx new file mode 100644 index 0000000..6ca5361 --- /dev/null +++ b/index.tsx @@ -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( + + + +); \ No newline at end of file diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..026788a --- /dev/null +++ b/metadata.json @@ -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" + ] +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..097f55a --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/pages/Dashboard.tsx b/pages/Dashboard.tsx new file mode 100644 index 0000000..6ce0f58 --- /dev/null +++ b/pages/Dashboard.tsx @@ -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 = ({ 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(null); + const [activeFilter, setActiveFilter] = useState('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
Acesso Negado. Faça login.
; + + 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 ( +
+

Meus Eventos

+

Acompanhe seus eventos ou solicite novos orçamentos.

+
+ ); + } + if (user.role === UserRole.PHOTOGRAPHER) { + return ( +
+

Eventos Designados

+

Gerencie seus trabalhos e realize uploads.

+
+ ); + } + return ( +
+

Gestão Geral

+

Controle total de eventos, aprovações e equipes.

+
+ ); + }; + + const renderRoleSpecificActions = () => { + if (user.role === UserRole.PHOTOGRAPHER) return null; + + const label = user.role === UserRole.EVENT_OWNER ? "Solicitar Novo Evento" : "Novo Evento"; + + return ( + + ); + }; + + const renderAdminActions = (event: EventData) => { + if (user.role !== UserRole.BUSINESS_OWNER && user.role !== UserRole.SUPERADMIN) return null; + + if (event.status === EventStatus.PENDING_APPROVAL) { + return ( +
+ +
+ ); + } + return null; + }; + + // --- MAIN RENDER --- + + return ( +
+
+ + {/* Header */} + {view === 'list' && ( +
+ {renderRoleSpecificHeader()} + {renderRoleSpecificActions()} +
+ )} + + {/* Content Switcher */} + {view === 'list' && ( +
+ {/* Filters Bar */} +
+
+ + setSearchTerm(e.target.value)} + /> +
+ + {(user.role === UserRole.BUSINESS_OWNER || user.role === UserRole.SUPERADMIN) && ( +
+ + +
+ )} +
+ + {/* Grid */} +
+ {filteredEvents.map(event => ( +
+ {renderAdminActions(event)} + { setSelectedEvent(event); setView('details'); }} + /> +
+ ))} +
+ + {filteredEvents.length === 0 && ( +
+

Nenhum evento encontrado com os filtros atuais.

+
+ )} +
+ )} + + {(view === 'create' || view === 'edit') && ( + setView(view === 'edit' ? 'details' : 'list')} + onSubmit={handleSaveEvent} + initialData={view === 'edit' ? selectedEvent : undefined} + /> + )} + + {view === 'details' && selectedEvent && ( +
+ + + {/* Status Banner */} + {selectedEvent.status === EventStatus.PENDING_APPROVAL && user.role === UserRole.EVENT_OWNER && ( +
+ +
+

Solicitação em Análise

+

Seu evento foi enviado e está aguardando aprovação da equipe Photum.

+
+
+ )} + +
+
+ Cover +
+

{selectedEvent.name}

+
+
+ +
+
+
+ {/* Actions Toolbar */} +
+ {user.role === UserRole.PHOTOGRAPHER && ( + + )} + {(user.role === UserRole.BUSINESS_OWNER || user.role === UserRole.SUPERADMIN) && ( + <> + + + + )} + {user.role === UserRole.EVENT_OWNER && selectedEvent.status !== EventStatus.ARCHIVED && ( + + )} +
+ +
+

Sobre o Evento

+

{selectedEvent.briefing || "Sem briefing detalhado."}

+
+ + {selectedEvent.contacts.length > 0 && ( +
+

Contatos & Responsáveis

+
+ {selectedEvent.contacts.map((c, i) => ( +
+

{c.name}

+

{c.role}

+

{c.phone}

+
+ ))} +
+
+ )} +
+ +
+
+

Status Atual

+

{selectedEvent.status}

+
+ +
+

Localização

+

{selectedEvent.address.street}, {selectedEvent.address.number}

+

{selectedEvent.address.city} - {selectedEvent.address.state}

+ + {selectedEvent.address.mapLink ? ( + + ) : ( + + )} + +
+ + {(selectedEvent.photographerIds.length > 0 || user.role === UserRole.BUSINESS_OWNER) && ( +
+
+

Equipe Designada

+ {(user.role === UserRole.BUSINESS_OWNER || user.role === UserRole.SUPERADMIN) && ( + + )} +
+ + {selectedEvent.photographerIds.length > 0 ? ( +
+ {selectedEvent.photographerIds.map((id, idx) => ( +
+ ))} +
+ ) : ( +

Nenhum profissional atribuído.

+ )} +
+ )} +
+
+
+
+
+ )} + + {view === 'uploads' && ( +
+ {/* Check if user came from 'details' of a selected event OR came from Navbar */} + {selectedEvent ? ( +
+ + +
+
+

Galeria de Evento: {selectedEvent.name}

+

Gerencie as fotos e faça novos uploads.

+
+ +
+ + {/* Drag and Drop Area */} +
+ +

Adicionar Novas Fotos

+

Clique aqui para simular o upload de uma nova imagem

+
+ + {/* Gallery Grid */} +
+

+ + Fotos do Evento ({selectedEvent.attachments.filter(a => a.type.startsWith('image')).length}) +

+ + {selectedEvent.attachments.length > 0 ? ( +
+ {selectedEvent.attachments.map((file, idx) => ( +
+ {file.url ? ( + {file.name} + ) : ( +
+ +
+ )} +
+ {file.name} +
+
+ ))} +
+ ) : ( +
+

Nenhuma foto carregada ainda.

+
+ )} +
+
+ ) : ( + // Logic when clicking "Meus Uploads" in navbar: Select an Event first +
+

Selecione um evento para gerenciar uploads

+
+ {myEvents.map(event => ( +
setSelectedEvent(event)} + > +

{event.name}

+

{new Date(event.date).toLocaleDateString()}

+
+ + {event.attachments.length} arquivos +
+
+ ))} + {myEvents.length === 0 && ( +

Você não possui eventos designados no momento.

+ )} +
+
+ )} +
+ )} +
+
+ ); +}; diff --git a/pages/Home.tsx b/pages/Home.tsx new file mode 100644 index 0000000..139526a --- /dev/null +++ b/pages/Home.tsx @@ -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 = ({ onEnter }) => { + const [currentSlide, setCurrentSlide] = useState(0); + + useEffect(() => { + const timer = setInterval(() => { + setCurrentSlide((prev) => (prev + 1) % HERO_IMAGES.length); + }, 5000); + return () => clearInterval(timer); + }, []); + + return ( +
+ {/* Hero Section */} +
+ {HERO_IMAGES.map((img, idx) => ( +
+ Hero +
+
+ ))} + +
+
+

+ Eternizando Momentos
+ Únicos +

+

+ Gestão completa para eventos inesquecíveis. Do planejamento à entrega do álbum perfeito. +

+
+ + +
+
+
+ + {/* Carousel Dots */} +
+ {HERO_IMAGES.map((_, idx) => ( +
+
+ + {/* Features Section */} +
+
+
+

Por que nós?

+

Excelência em cada detalhe

+
+ +
+ {[ + { icon: , title: "Qualidade Impecável", desc: "Equipamentos de última geração e profissionais premiados." }, + { icon: , title: "Segurança Total", desc: "Backup duplo em nuvem e contratos transparentes." }, + { icon: , title: "Atendimento Humanizado", desc: "Entendemos que seu evento é um sonho a ser realizado." } + ].map((feature, idx) => ( +
+
+ {feature.icon} +
+

{feature.title}

+

{feature.desc}

+
+ ))} +
+
+
+ + {/* Testimonials */} +
+
+ +
+ "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." +
+ + Mariana & Pedro + Casamento em Campos do Jordão + +
+
+
+ ); +}; \ No newline at end of file diff --git a/pages/Login.tsx b/pages/Login.tsx new file mode 100644 index 0000000..ada005d --- /dev/null +++ b/pages/Login.tsx @@ -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 ( +
+ {/* Left Side - Image */} +
+ Photum Login +
+
+

Photum Manager

+

Gestão de eventos premium para quem não abre mão da excelência.

+
+
+
+ + {/* Right Side - Form */} +
+
+
+ Bem-vindo de volta +

Acesse sua conta

+
+ +
+
+ setEmail(e.target.value)} + error={error} + /> + +
+ + +
+ + {/* Demo Users Quick Select */} +
+

Usuários de Demonstração (Clique para preencher)

+
+ {availableUsers.map(user => ( + + ))} +
+
+
+
+
+ ); +}; diff --git a/services/genaiService.ts b/services/genaiService.ts new file mode 100644 index 0000000..e2de43a --- /dev/null +++ b/services/genaiService.ts @@ -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 => { + 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((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 []; + } +}; diff --git a/services/mockGeoService.ts b/services/mockGeoService.ts new file mode 100644 index 0000000..0e697ee --- /dev/null +++ b/services/mockGeoService.ts @@ -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` + } + ]; +}; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2c6eed5 --- /dev/null +++ b/tsconfig.json @@ -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 + } +} \ No newline at end of file diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..20ce074 --- /dev/null +++ b/types.ts @@ -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 +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..ee5fb8d --- /dev/null +++ b/vite.config.ts @@ -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, '.'), + } + } + }; +});