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.
405 lines
No EOL
18 KiB
TypeScript
405 lines
No EOL
18 KiB
TypeScript
|
|
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>
|
|
);
|
|
}; |