photum/components/EventForm.tsx
João Vitor d087cefb1b feat: Integração completa Mapbox + Upload de avatares
- Integração Mapbox GL JS para seleção interativa de localização
  - Mapa arrastável com pin para localização exata
  - Geocoding e reverse geocoding automático
  - Busca de endereços com autocomplete
  - Campos editáveis que atualizam mapa automaticamente
  - Token configurado via variável de ambiente (.env.local)

- Sistema de upload de fotos de fotógrafos
  - Upload via input de arquivo (substituiu URL)
  - Preview automático com FileReader API
  - Botão para remover foto selecionada
  - Placeholder com ícone de câmera

- Remoção de funcionalidades de uploads/álbuns
  - Removida página Albums.tsx
  - Removido sistema de attachments
  - Removida aba Inspiração para empresas
  - Criada página Inspiração com galeria de exemplo

- Melhorias de responsividade
  - Cards do mapa adaptados para mobile
  - Texto e padding reduzidos em telas pequenas

- Arquivos de configuração
  - .env.example criado
  - vite-env.d.ts para tipagem
  - MAPBOX_SETUP.md com instruções
  - Footer atualizado com serviços universitários
2025-12-02 13:55:56 -03:00

673 lines
No EOL
28 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, Building2, AlertCircle } from 'lucide-react';
import { searchMapboxLocation, MapboxResult, reverseGeocode } from '../services/mapboxService';
import { useAuth } from '../contexts/AuthContext';
import { useData } from '../contexts/DataContext';
import { UserRole } from '../types';
import { InstitutionForm } from './InstitutionForm';
import { MapboxMap } from './MapboxMap';
interface EventFormProps {
onCancel: () => void;
onSubmit: (data: any) => void;
initialData?: any;
}
export const EventForm: React.FC<EventFormProps> = ({ onCancel, onSubmit, initialData }) => {
const { user } = useAuth();
const { institutions, getInstitutionsByUserId, addInstitution } = useData();
const [activeTab, setActiveTab] = useState<'details' | 'location' | 'briefing' | 'files'>('details');
const [addressQuery, setAddressQuery] = useState('');
const [addressResults, setAddressResults] = useState<MapboxResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [isGeocoding, setIsGeocoding] = useState(false);
const [showToast, setShowToast] = useState(false);
const [showInstitutionForm, setShowInstitutionForm] = useState(false);
// Get institutions based on user role
// Business owners and admins see all institutions, clients see only their own
const userInstitutions = user
? (user.role === UserRole.BUSINESS_OWNER || user.role === UserRole.SUPERADMIN
? institutions
: getInstitutionsByUserId(user.id))
: [];
// Default State or Initial Data
const [formData, setFormData] = useState(initialData || {
name: '',
date: '',
time: '',
type: '',
status: EventStatus.PLANNING,
address: {
street: '',
number: '',
city: '',
state: '',
zip: '',
lat: -23.5505,
lng: -46.6333,
mapLink: ''
} as Address,
briefing: '',
contacts: [{ name: '', role: '', phone: '' }],
files: [] as File[],
coverImage: 'https://images.unsplash.com/photo-1511795409834-ef04bbd61622?ixlib=rb-1.2.1&auto=format&fit=crop&w=800&q=80', // Default
institutionId: ''
});
const isClientRequest = user?.role === UserRole.EVENT_OWNER;
const formTitle = initialData
? "Editar Evento"
: (isClientRequest ? "Solicitar Orçamento/Evento" : "Cadastrar Novo Evento");
const submitLabel = initialData ? "Salvar Alterações" : (isClientRequest ? "Enviar Solicitação" : "Criar Evento");
// Address Autocomplete Logic using Mapbox
useEffect(() => {
const timer = setTimeout(async () => {
if (addressQuery.length > 3) {
setIsSearching(true);
const results = await searchMapboxLocation(addressQuery);
setAddressResults(results);
setIsSearching(false);
} else {
setAddressResults([]);
}
}, 500);
return () => clearTimeout(timer);
}, [addressQuery]);
const handleAddressSelect = (addr: MapboxResult) => {
setFormData((prev: any) => ({
...prev,
address: {
street: addr.street,
number: addr.number,
city: addr.city,
state: addr.state,
zip: addr.zip,
lat: addr.lat,
lng: addr.lng,
mapLink: addr.mapLink
}
}));
setAddressQuery('');
setAddressResults([]);
};
const handleMapLocationChange = async (lat: number, lng: number) => {
// Buscar endereço baseado nas coordenadas
const addressData = await reverseGeocode(lat, lng);
if (addressData) {
setFormData((prev: any) => ({
...prev,
address: {
street: addressData.street,
number: addressData.number,
city: addressData.city,
state: addressData.state,
zip: addressData.zip,
lat: addressData.lat,
lng: addressData.lng,
mapLink: addressData.mapLink
}
}));
} else {
// Se não conseguir o endereço, atualiza apenas as coordenadas
setFormData((prev: any) => ({
...prev,
address: {
...prev.address,
lat,
lng,
mapLink: `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`
}
}));
}
};
// Geocoding quando o usuário digita o endereço manualmente
const handleManualAddressChange = async () => {
const { street, number, city, state } = formData.address;
// Montar query de busca
const query = `${street} ${number}, ${city}, ${state}`.trim();
if (query.length < 5) return; // Endereço muito curto
setIsGeocoding(true);
try {
const results = await searchMapboxLocation(query);
if (results.length > 0) {
const firstResult = results[0];
setFormData((prev: any) => ({
...prev,
address: {
...prev.address,
lat: firstResult.lat,
lng: firstResult.lng,
mapLink: firstResult.mapLink
}
}));
}
} catch (error) {
console.error('Erro ao geocodificar endereço manual:', error);
} finally {
setIsGeocoding(false);
}
};
const addContact = () => {
setFormData((prev: any) => ({
...prev,
contacts: [...prev.contacts, { name: '', role: '', phone: '' }]
}));
};
const removeContact = (index: number) => {
const newContacts = [...formData.contacts];
newContacts.splice(index, 1);
setFormData((prev: any) => ({ ...prev, contacts: newContacts }));
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
setFormData((prev: any) => ({
...prev,
files: [...prev.files, ...Array.from(e.target.files || [])]
}));
}
};
const handleSubmit = () => {
// Validate institution selection
if (!formData.institutionId) {
alert('Por favor, selecione uma instituição antes de continuar.');
return;
}
// Show toast
setShowToast(true);
// Call original submit after small delay for visual effect or immediately
setTimeout(() => {
onSubmit(formData);
}, 1000);
};
const handleInstitutionSubmit = (institutionData: any) => {
const newInstitution = {
...institutionData,
id: `inst-${Date.now()}`,
ownerId: user?.id || ''
};
addInstitution(newInstitution);
setFormData(prev => ({ ...prev, institutionId: newInstitution.id }));
setShowInstitutionForm(false);
};
// Show institution form modal
if (showInstitutionForm) {
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<InstitutionForm
onCancel={() => setShowInstitutionForm(false)}
onSubmit={handleInstitutionSubmit}
userId={user?.id || ''}
/>
</div>
);
}
return (
<div className="bg-white rounded-lg shadow-xl overflow-hidden max-w-4xl mx-auto border border-gray-100 slide-up relative">
{/* Success Toast */}
{showToast && (
<div className="absolute top-4 right-4 z-50 bg-brand-black text-white px-6 py-4 rounded shadow-2xl flex items-center space-x-3 fade-in">
<CheckCircle className="text-brand-gold h-6 w-6" />
<div>
<h4 className="font-bold text-sm">Sucesso!</h4>
<p className="text-xs text-gray-300">As informações foram salvas.</p>
</div>
</div>
)}
{/* Form Header */}
<div className="bg-gray-50 border-b px-8 py-6 flex justify-between items-center">
<div>
<h2 className="text-2xl font-serif text-brand-black">{formTitle}</h2>
<p className="text-sm text-gray-500 mt-1">
{isClientRequest
? "Preencha os detalhes do seu sonho. Nossa equipe analisará em breve."
: "Preencha as informações técnicas do evento."}
</p>
</div>
<div className="flex space-x-2">
{['details', 'location', 'briefing', 'files'].map((tab, idx) => (
<div key={tab} className={`flex flex-col items-center ${activeTab === tab ? 'opacity-100' : 'opacity-40'}`}>
<span className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold mb-1 ${activeTab === tab ? 'bg-brand-black text-white' : 'bg-gray-200 text-gray-600'}`}>
{idx + 1}
</span>
</div>
))}
</div>
</div>
<div className="grid grid-cols-4 min-h-[500px]">
{/* Sidebar Navigation for Form */}
<div className="col-span-1 border-r border-gray-100 bg-gray-50/50 p-4 space-y-2">
{[
{ id: 'details', label: 'Detalhes & Data' },
{ id: 'location', label: 'Localização' },
{ id: 'briefing', label: isClientRequest ? 'Seus Desejos' : 'Briefing & Equipe' },
{ id: 'files', label: 'Inspirações' }
].map(item => (
<button
key={item.id}
onClick={() => setActiveTab(item.id as any)}
className={`w-full text-left px-4 py-3 rounded-sm text-sm font-medium transition-colors ${activeTab === item.id
? 'bg-white shadow-sm text-brand-gold border-l-4 border-brand-gold'
: 'text-gray-500 hover:bg-gray-100'
}`}
>
{item.label}
</button>
))}
</div>
{/* Form Content */}
<div className="col-span-3 p-8">
{activeTab === 'details' && (
<div className="space-y-6 fade-in">
<div className="grid grid-cols-1 gap-6">
<Input
label="Nome do Evento (Opcional)"
placeholder="Ex: Casamento Silva & Souza"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
<div className="grid grid-cols-2 gap-4">
<Input
label="Data Pretendida"
type="date"
value={formData.date}
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
/>
<Input
label="Horário Aproximado"
type="time"
value={formData.time}
onChange={(e) => setFormData({ ...formData, time: e.target.value })}
/>
</div>
<Select
label="Tipo de Evento"
options={Object.values(EventType).map(t => ({ value: t, label: t }))}
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
/>
{/* Institution Selection - OBRIGATÓRIO */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1 tracking-wide uppercase text-xs">
Universidade* <span className="text-brand-gold">(Obrigatório)</span>
</label>
{userInstitutions.length === 0 ? (
<div className="border-2 border-dashed border-amber-300 bg-amber-50 rounded-sm p-4">
<div className="flex items-start space-x-3">
<AlertCircle className="text-amber-600 flex-shrink-0 mt-0.5" size={20} />
<div className="flex-1">
<p className="text-sm font-medium text-amber-900 mb-2">
Nenhuma universidade cadastrada
</p>
<p className="text-xs text-amber-700 mb-3">
Você precisa cadastrar uma universidade antes de criar um evento.
Trabalhamos exclusivamente com eventos fotográficos em universidades.
</p>
<button
type="button"
onClick={() => setShowInstitutionForm(true)}
className="text-xs font-bold text-amber-900 hover:text-amber-700 underline flex items-center"
>
<Plus size={14} className="mr-1" />
Cadastrar minha primeira universidade
</button>
</div>
</div>
</div>
) : (
<div className="space-y-2">
<select
className="w-full px-4 py-2 border border-gray-300 rounded-sm focus:outline-none focus:ring-1 focus:ring-brand-gold focus:border-brand-gold transition-colors"
value={formData.institutionId}
onChange={(e) => setFormData({ ...formData, institutionId: e.target.value })}
required
>
<option value="">Selecione uma universidade</option>
{userInstitutions.map(inst => (
<option key={inst.id} value={inst.id}>
{inst.name} - {inst.type}
</option>
))}
</select>
<button
type="button"
onClick={() => setShowInstitutionForm(true)}
className="text-xs text-brand-gold hover:underline flex items-center"
>
<Plus size={12} className="mr-1" />
Cadastrar nova universidade
</button>
{formData.institutionId && (
<div className="bg-green-50 border border-green-200 rounded-sm p-3 flex items-center">
<Check size={16} className="text-green-600 mr-2" />
<span className="text-xs text-green-800">Universidade selecionada com sucesso</span>
</div>
)}
</div>
)}
</div>
{/* Cover Image Upload */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1 tracking-wide uppercase text-xs">
Imagem de Capa
</label>
<div className="relative border border-gray-300 rounded-sm p-2 flex items-center bg-white">
<input
type="file"
accept="image/*"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
onChange={(e) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
const imageUrl = URL.createObjectURL(file);
setFormData({ ...formData, coverImage: imageUrl });
}
}}
/>
<div className="flex items-center justify-between w-full px-2">
<span className="text-sm text-gray-500 truncate max-w-[200px]">
{formData.coverImage && !formData.coverImage.startsWith('http')
? "Imagem selecionada"
: (formData.coverImage ? "Imagem atual (URL)" : "Clique para selecionar...")}
</span>
<div className="bg-gray-100 p-1.5 rounded hover:bg-gray-200">
<Upload size={16} className="text-gray-600" />
</div>
</div>
</div>
{formData.coverImage && (
<div className="mt-2 h-32 w-full rounded-sm overflow-hidden border border-gray-200 relative group">
<img src={formData.coverImage} alt="Preview" className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-white text-xs">
Visualização da Capa
</div>
</div>
)}
</div>
</div>
<div className="flex justify-end mt-8">
<Button onClick={() => setActiveTab('location')}>Próximo: Localização</Button>
</div>
</div>
)}
{activeTab === 'location' && (
<div className="space-y-6 fade-in">
<div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-1 tracking-wide uppercase text-xs">
Busca de Endereço (Powered by Mapbox)
</label>
<div className="relative">
<input
className="w-full px-4 py-2 border border-gray-300 rounded-sm focus:outline-none focus:ring-1 focus:ring-brand-gold focus:border-brand-gold transition-colors pr-10"
placeholder="Digite o nome do local ou endereço..."
value={addressQuery}
onChange={(e) => setAddressQuery(e.target.value)}
/>
<div className="absolute right-3 top-2.5 text-gray-400">
{isSearching ? (
<div className="animate-spin h-5 w-5 border-2 border-brand-gold rounded-full border-t-transparent"></div>
) : (
<Search size={20} />
)}
</div>
</div>
{addressResults.length > 0 && (
<ul className="absolute z-10 w-full bg-white border mt-1 shadow-lg rounded-sm max-h-64 overflow-y-auto">
{addressResults.map((addr, idx) => (
<li
key={idx}
className="px-4 py-3 hover:bg-gray-50 cursor-pointer text-sm border-b border-gray-50 last:border-0"
onClick={() => handleAddressSelect(addr)}
>
<div className="flex justify-between items-start">
<div className="flex items-start">
<MapPin size={16} className="mt-0.5 mr-2 text-brand-gold flex-shrink-0" />
<div>
<p className="font-medium text-gray-800">{addr.description}</p>
<p className="text-xs text-gray-500 mt-0.5">{addr.city}, {addr.state}</p>
</div>
</div>
{addr.mapLink && (
<span className="flex items-center text-[10px] text-blue-600 bg-blue-50 px-2 py-1 rounded ml-2">
<img src="https://www.google.com/images/branding/product/ico/maps15_bnuw3a_32dp.png" alt="Maps" className="w-3 h-3 mr-1" />
Maps
</span>
)}
</div>
</li>
))}
</ul>
)}
</div>
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2">
<Input
label="Rua"
value={formData.address.street}
onChange={(e) => {
setFormData({ ...formData, address: { ...formData.address, street: e.target.value } });
}}
onBlur={handleManualAddressChange}
placeholder="Digite o nome da rua"
/>
</div>
<Input
label="Número"
placeholder="123"
value={formData.address.number}
onChange={(e) => {
const value = e.target.value;
setFormData({ ...formData, address: { ...formData.address, number: value } });
}}
onBlur={handleManualAddressChange}
type="text"
inputMode="numeric"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Input
label="Cidade"
value={formData.address.city}
onChange={(e) => {
setFormData({ ...formData, address: { ...formData.address, city: e.target.value } });
}}
onBlur={handleManualAddressChange}
placeholder="Digite a cidade"
/>
<Input
label="Estado"
value={formData.address.state}
onChange={(e) => {
const value = e.target.value.toUpperCase().slice(0, 2);
setFormData({ ...formData, address: { ...formData.address, state: value } });
}}
onBlur={handleManualAddressChange}
placeholder="SP"
maxLength={2}
/>
</div>
{/* Mapa Interativo */}
<div className="mt-6">
<label className="block text-sm font-medium text-gray-700 mb-3 tracking-wide uppercase text-xs flex items-center justify-between">
<span>📍 Mapa Interativo - Ajuste a Localização Exata</span>
{isGeocoding && (
<span className="text-xs text-brand-gold flex items-center normal-case">
<div className="animate-spin h-3 w-3 border-2 border-brand-gold rounded-full border-t-transparent mr-2"></div>
Localizando no mapa...
</span>
)}
</label>
<MapboxMap
initialLat={formData.address.lat || -23.5505}
initialLng={formData.address.lng || -46.6333}
onLocationChange={handleMapLocationChange}
height="450px"
/>
</div>
{formData.address.mapLink && (
<div className="bg-gray-50 p-3 rounded border border-gray-200 flex items-center justify-between">
<span className="text-xs text-gray-500 flex items-center">
<Check size={14} className="mr-1 text-green-500" />
Localização verificada via Mapbox
</span>
<a href={formData.address.mapLink} target="_blank" rel="noreferrer" className="text-xs text-brand-gold flex items-center hover:underline">
Ver no mapa <ExternalLink size={12} className="ml-1" />
</a>
</div>
)}
<div className="flex justify-between mt-8">
<Button variant="outline" onClick={() => setActiveTab('details')}>Voltar</Button>
<Button onClick={() => setActiveTab('briefing')}>Próximo</Button>
</div>
</div>
)}
{activeTab === 'briefing' && (
<div className="space-y-6 fade-in">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1 tracking-wide uppercase text-xs">
{isClientRequest ? "Conte-nos sobre o seu sonho" : "Briefing Técnico"}
</label>
<textarea
className="w-full border border-gray-300 rounded-sm p-3 focus:outline-none focus:border-brand-gold h-32 text-sm"
placeholder={isClientRequest ? "Qual o estilo do casamento? Quais fotos são indispensáveis? Fale um pouco sobre vocês..." : "Instruções técnicas..."}
value={formData.briefing}
onChange={(e) => setFormData({ ...formData, briefing: e.target.value })}
></textarea>
</div>
<div>
<div className="flex justify-between items-center mb-2">
<label className="text-sm font-medium text-gray-700 tracking-wide uppercase text-xs">Contatos / Responsáveis</label>
<button onClick={addContact} className="text-xs text-brand-gold font-bold hover:underline flex items-center">
<Plus size={14} className="mr-1" /> Adicionar
</button>
</div>
<div className="space-y-3">
{formData.contacts.map((contact: any, idx: number) => (
<div key={idx} className="flex space-x-2 items-start">
<Input
label={idx === 0 ? "Nome" : ""}
placeholder="Nome"
value={contact.name}
onChange={(e) => {
const newContacts = [...formData.contacts];
newContacts[idx].name = e.target.value;
setFormData({ ...formData, contacts: newContacts });
}}
/>
<Input
label={idx === 0 ? "Papel" : ""}
placeholder="Ex: Cerimonialista"
value={contact.role}
onChange={(e) => {
const newContacts = [...formData.contacts];
newContacts[idx].role = e.target.value;
setFormData({ ...formData, contacts: newContacts });
}}
/>
<button
onClick={() => removeContact(idx)}
className={`mt-1 p-2 text-gray-400 hover:text-red-500 ${idx === 0 ? 'mt-7' : ''}`}
>
<X size={16} />
</button>
</div>
))}
</div>
</div>
<div className="flex justify-between mt-8">
<Button variant="outline" onClick={() => setActiveTab('location')}>Voltar</Button>
<Button onClick={() => setActiveTab('files')}>Próximo</Button>
</div>
</div>
)}
{activeTab === 'files' && (
<div className="space-y-6 fade-in">
<div className="border-2 border-dashed border-gray-300 rounded-lg p-10 text-center hover:bg-gray-50 transition-colors relative">
<input
type="file"
multiple
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
onChange={handleFileUpload}
/>
<Upload size={40} className="mx-auto text-gray-400 mb-4" />
<p className="text-sm text-gray-600 font-medium">
{isClientRequest ? "Anexe referências visuais (Moodboard)" : "Anexe contratos e cronogramas"}
</p>
<p className="text-xs text-gray-400 mt-1">PDF, JPG, PNG (Max 10MB)</p>
</div>
{formData.files.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-medium text-gray-700">Arquivos Selecionados:</h4>
{formData.files.map((file: any, idx: number) => (
<div key={idx} className="flex items-center justify-between p-3 bg-gray-50 rounded border border-gray-100">
<div className="flex items-center">
<FileText size={18} className="text-brand-gold mr-3" />
<div>
<p className="text-sm font-medium">{file.name}</p>
<p className="text-xs text-gray-400">{(file.size / 1024).toFixed(1)} KB</p>
</div>
</div>
<Check size={16} className="text-green-500" />
</div>
))}
</div>
)}
<div className="flex justify-between mt-8">
<Button variant="outline" onClick={() => setActiveTab('briefing')}>Voltar</Button>
<Button onClick={handleSubmit} variant="secondary">{submitLabel}</Button>
</div>
</div>
)}
</div>
</div>
</div>
);
};