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
This commit is contained in:
João Vitor 2025-12-02 13:55:56 -03:00
parent 7f5c8ae2be
commit d087cefb1b
17 changed files with 1169 additions and 363 deletions

12
.env.example Normal file
View file

@ -0,0 +1,12 @@
# Configuração da API do Mapbox
# Obtenha sua chave gratuita em: https://account.mapbox.com/
# Para usar:
# 1. Copie este arquivo para .env.local
# 2. Substitua YOUR_MAPBOX_TOKEN_HERE pela sua chave real
# 3. Atualize o arquivo services/mapboxService.ts com sua chave
VITE_MAPBOX_TOKEN=YOUR_MAPBOX_TOKEN_HERE
# Nota: A chave já está configurada diretamente no código para demonstração
# Em produção, use variáveis de ambiente como acima

17
App.tsx
View file

@ -8,7 +8,7 @@ import { CalendarPage } from "./pages/Calendar";
import { TeamPage } from "./pages/Team";
import { FinancePage } from "./pages/Finance";
import { SettingsPage } from "./pages/Settings";
import { AlbumsPage } from "./pages/Albums";
import { InspirationPage } from "./pages/Inspiration";
import { PrivacyPolicy } from "./pages/PrivacyPolicy";
import { TermsOfUse } from "./pages/TermsOfUse";
import { LGPD } from "./pages/LGPD";
@ -47,8 +47,8 @@ const AppContent: React.FC = () => {
case "request-event":
return <Dashboard initialView="create" />;
case "uploads":
return <Dashboard initialView="uploads" />;
case "inspiration":
return <InspirationPage />;
case "calendar":
return <CalendarPage />;
@ -62,9 +62,6 @@ const AppContent: React.FC = () => {
case "settings":
return <SettingsPage />;
case "albums":
return <AlbumsPage />;
default:
return <Dashboard initialView="list" />;
}
@ -91,10 +88,10 @@ const AppContent: React.FC = () => {
<div>
<h4 className="font-bold text-brand-black mb-4 md:mb-6 uppercase tracking-wider text-sm sm:text-base md:text-lg">Serviços</h4>
<ul className="space-y-2 md:space-y-3 text-brand-black/70 text-sm sm:text-base md:text-lg">
<li>Fotografia de Eventos</li>
<li>Álbuns Personalizados</li>
<li>Gestão de Formaturas</li>
<li>Cobertura Completa</li>
<li>Fotografia de Formatura</li>
<li>Baile de Gala</li>
<li>Cerimônia de Colação</li>
<li>Ensaios de Turma</li>
</ul>
</div>

72
MAPBOX_SETUP.md Normal file
View file

@ -0,0 +1,72 @@
# 🗺️ Configuração do Mapbox
## Token do Mapbox Inválido
O sistema está configurado para usar a API do Mapbox, mas o token atual é inválido ou expirado.
## Como Obter um Token Válido (GRATUITO)
### 1. Crie uma conta no Mapbox
- Acesse: **https://account.mapbox.com/**
- Clique em **"Sign up"** (ou faça login se já tiver conta)
- É **100% gratuito** para até 50.000 requisições/mês
### 2. Acesse a página de Tokens
- Após fazer login, vá para: **https://account.mapbox.com/access-tokens/**
- Ou clique no menu em **"Access tokens"**
### 3. Crie um novo token
- Clique em **"Create a token"**
- Dê um nome (ex: "Photum Forms")
- Selecione os escopos necessários (deixe os padrões marcados)
- Clique em **"Create token"**
### 4. Copie o token
- O token será algo como: `pk.eyJ1IjoiZXhhbXBsZSIsImEiOiJja...`
- **COPIE O TOKEN COMPLETO**
### 5. Configure no Projeto
Abra o arquivo **`services/mapboxService.ts`** e substitua o token na linha 26:
```typescript
const MAPBOX_TOKEN = 'SEU_TOKEN_AQUI'; // Cole o token do Mapbox aqui
```
### 6. Salve e recarregue
Após salvar o arquivo, o Vite recarregará automaticamente e o mapa funcionará!
---
## Verificação
Se tudo estiver correto, você verá:
- ✅ "Inicializando mapa Mapbox..." no console
- ✅ "Mapa criado com sucesso" no console
- ✅ Mapa interativo carregado na tela de criação de eventos
Se houver erro:
- ❌ Verifique se copiou o token completo (incluindo `pk.`)
- ❌ Verifique se não há espaços extras antes/depois do token
- ❌ Certifique-se de que o token não expirou
---
## Recursos do Mapbox no Sistema
- 🔍 Busca de endereços com autocomplete
- 📍 Mapa interativo com pin arrastável
- 🌍 Geocoding e Reverse Geocoding
- 🗺️ Integração com Google Maps para compartilhamento
## Limites Gratuitos
O plano gratuito do Mapbox inclui:
- 50.000 requisições de geocoding/mês
- 50.000 carregamentos de mapa/mês
- Mais que suficiente para este projeto!
---
**Precisa de ajuda?** Acesse a documentação: https://docs.mapbox.com/api/

View file

@ -4,11 +4,12 @@ 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 { searchLocationWithGemini, GeoResult } from '../services/genaiService';
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;
@ -21,8 +22,9 @@ export const EventForm: React.FC<EventFormProps> = ({ onCancel, onSubmit, initia
const { institutions, getInstitutionsByUserId, addInstitution } = useData();
const [activeTab, setActiveTab] = useState<'details' | 'location' | 'briefing' | 'files'>('details');
const [addressQuery, setAddressQuery] = useState('');
const [addressResults, setAddressResults] = useState<GeoResult[]>([]);
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);
@ -41,7 +43,16 @@ export const EventForm: React.FC<EventFormProps> = ({ onCancel, onSubmit, initia
time: '',
type: '',
status: EventStatus.PLANNING,
address: { street: '', number: '', city: '', state: '', zip: '', mapLink: '' } as Address,
address: {
street: '',
number: '',
city: '',
state: '',
zip: '',
lat: -23.5505,
lng: -46.6333,
mapLink: ''
} as Address,
briefing: '',
contacts: [{ name: '', role: '', phone: '' }],
files: [] as File[],
@ -55,22 +66,22 @@ export const EventForm: React.FC<EventFormProps> = ({ onCancel, onSubmit, initia
: (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
// Address Autocomplete Logic using Mapbox
useEffect(() => {
const timer = setTimeout(async () => {
if (addressQuery.length > 4) { // Increased threshold to avoid spamming API
if (addressQuery.length > 3) {
setIsSearching(true);
const results = await searchLocationWithGemini(addressQuery);
const results = await searchMapboxLocation(addressQuery);
setAddressResults(results);
setIsSearching(false);
} else {
setAddressResults([]);
}
}, 800); // Increased debounce for API efficiency
}, 500);
return () => clearTimeout(timer);
}, [addressQuery]);
const handleAddressSelect = (addr: GeoResult) => {
const handleAddressSelect = (addr: MapboxResult) => {
setFormData((prev: any) => ({
...prev,
address: {
@ -79,6 +90,8 @@ export const EventForm: React.FC<EventFormProps> = ({ onCancel, onSubmit, initia
city: addr.city,
state: addr.state,
zip: addr.zip,
lat: addr.lat,
lng: addr.lng,
mapLink: addr.mapLink
}
}));
@ -86,6 +99,72 @@ export const EventForm: React.FC<EventFormProps> = ({ onCancel, onSubmit, initia
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,
@ -351,7 +430,7 @@ export const EventForm: React.FC<EventFormProps> = ({ onCancel, onSubmit, initia
<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)
Busca de Endereço (Powered by Mapbox)
</label>
<div className="relative">
<input
@ -400,30 +479,76 @@ export const EventForm: React.FC<EventFormProps> = ({ onCancel, onSubmit, initia
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2">
<Input label="Rua" value={formData.address.street} readOnly />
<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.replace(/\D/g, '');
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} readOnly />
<Input label="Estado" value={formData.address.state} readOnly />
<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 Google Maps
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" />

195
components/MapboxMap.tsx Normal file
View file

@ -0,0 +1,195 @@
import React, { useEffect, useRef, useState } from 'react';
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
import { MapPin, Target } from 'lucide-react';
interface MapboxMapProps {
initialLat?: number;
initialLng?: number;
onLocationChange?: (lat: number, lng: number) => void;
height?: string;
}
export const MapboxMap: React.FC<MapboxMapProps> = ({
initialLat = -23.5505, // São Paulo como padrão
initialLng = -46.6333,
onLocationChange,
height = '400px'
}) => {
const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<mapboxgl.Map | null>(null);
const marker = useRef<mapboxgl.Marker | null>(null);
const [currentLat, setCurrentLat] = useState(initialLat);
const [currentLng, setCurrentLng] = useState(initialLng);
const [mapLoaded, setMapLoaded] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!mapContainer.current || map.current) return;
try {
console.log('🗺️ Inicializando mapa Mapbox...');
// Configurar token
const token = import.meta.env.VITE_MAPBOX_TOKEN;
if (!token) {
setError('❌ Token do Mapbox não encontrado. Configure VITE_MAPBOX_TOKEN no arquivo .env.local');
return;
}
mapboxgl.accessToken = token;
// Inicializar mapa
map.current = new mapboxgl.Map({
container: mapContainer.current,
style: 'mapbox://styles/mapbox/streets-v12',
center: [initialLng, initialLat],
zoom: 15
});
console.log('✅ Mapa criado com sucesso');
// Adicionar controles de navegação
map.current.addControl(new mapboxgl.NavigationControl(), 'top-right');
// Adicionar controle de localização
map.current.addControl(
new mapboxgl.GeolocateControl({
positionOptions: {
enableHighAccuracy: true
},
trackUserLocation: true,
showUserHeading: true
}),
'top-right'
);
// Criar marcador arrastável
marker.current = new mapboxgl.Marker({
color: '#c5a059',
draggable: true
})
.setLngLat([initialLng, initialLat])
.addTo(map.current);
// Evento quando o marcador é arrastado
marker.current.on('dragend', () => {
if (marker.current) {
const lngLat = marker.current.getLngLat();
setCurrentLat(lngLat.lat);
setCurrentLng(lngLat.lng);
if (onLocationChange) {
onLocationChange(lngLat.lat, lngLat.lng);
}
}
});
// Evento de clique no mapa para mover o marcador
map.current.on('click', (e) => {
if (marker.current) {
marker.current.setLngLat([e.lngLat.lng, e.lngLat.lat]);
setCurrentLat(e.lngLat.lat);
setCurrentLng(e.lngLat.lng);
if (onLocationChange) {
onLocationChange(e.lngLat.lat, e.lngLat.lng);
}
}
});
map.current.on('load', () => {
setMapLoaded(true);
});
} catch (error: any) {
console.error('Erro ao inicializar mapa:', error);
const errorMsg = error?.message || String(error);
if (errorMsg.includes('token') || errorMsg.includes('Unauthorized') || errorMsg.includes('401')) {
setError('❌ Token do Mapbox inválido. Obtenha um token gratuito em https://account.mapbox.com/ e configure em services/mapboxService.ts');
} else {
setError(`Erro ao carregar o mapa: ${errorMsg}`);
}
}
// Cleanup
return () => {
if (marker.current) {
marker.current.remove();
}
if (map.current) {
map.current.remove();
map.current = null;
}
};
}, []); // Executar apenas uma vez
// Atualizar posição do marcador quando as coordenadas mudarem externamente
useEffect(() => {
if (marker.current && map.current && mapLoaded) {
marker.current.setLngLat([initialLng, initialLat]);
map.current.flyTo({
center: [initialLng, initialLat],
zoom: 15,
duration: 1500
});
setCurrentLat(initialLat);
setCurrentLng(initialLng);
}
}, [initialLat, initialLng, mapLoaded]);
const centerOnMarker = () => {
if (map.current && marker.current) {
const lngLat = marker.current.getLngLat();
map.current.flyTo({
center: [lngLat.lng, lngLat.lat],
zoom: 17,
duration: 1000
});
}
};
return (
<div className="relative">
{error && (
<div className="absolute top-0 left-0 right-0 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded z-10">
{error}
</div>
)}
<div
ref={mapContainer}
className="w-full rounded-lg border-2 border-gray-300 overflow-hidden shadow-md"
style={{ height }}
/>
{/* Info overlay - Responsivo */}
<div className="absolute bottom-2 sm:bottom-4 left-2 sm:left-4 bg-white/95 backdrop-blur-sm px-2 py-2 sm:px-4 sm:py-3 rounded-lg shadow-lg border border-gray-200 max-w-[160px] sm:max-w-none">
<div className="flex items-center space-x-2 sm:space-x-3">
<MapPin size={16} className="text-brand-gold flex-shrink-0 hidden sm:block" />
<div className="min-w-0">
<p className="text-[10px] sm:text-xs text-gray-500 font-medium hidden sm:block">Coordenadas</p>
<p className="text-[10px] sm:text-sm font-mono text-gray-800 truncate">
{currentLat.toFixed(4)}, {currentLng.toFixed(4)}
</p>
</div>
</div>
</div>
{/* Botão de centralizar - Responsivo */}
<button
onClick={centerOnMarker}
className="absolute bottom-2 sm:bottom-4 right-2 sm:right-4 bg-white hover:bg-gray-50 p-2 sm:p-3 rounded-full shadow-lg border border-gray-200 transition-colors group"
title="Centralizar no marcador"
>
<Target size={18} className="text-gray-600 group-hover:text-brand-gold transition-colors sm:w-5 sm:h-5" />
</button>
{/* Instruções - Responsivo */}
<div className="mt-3 bg-blue-50 border border-blue-200 rounded-lg p-2 sm:p-3 text-xs sm:text-sm text-blue-800">
<p className="font-medium mb-1 text-xs sm:text-sm">💡 Como usar:</p>
<ul className="text-[11px] sm:text-xs space-y-0.5 sm:space-y-1 text-blue-700">
<li className="flex items-start"><span className="mr-1"></span><span><strong>Arraste o marcador</strong> para a posição exata</span></li>
<li className="flex items-start"><span className="mr-1"></span><span><strong>Clique no mapa</strong> para mover o marcador</span></li>
<li className="flex items-start hidden sm:flex"><span className="mr-1"></span><span>Use os <strong>controles</strong> para zoom e navegação</span></li>
</ul>
</div>
</div>
);
};

View file

@ -39,13 +39,11 @@ export const Navbar: React.FC<NavbarProps> = ({ onNavigate, currentPage }) => {
case UserRole.EVENT_OWNER:
return [
{ name: 'Meus Eventos', path: 'dashboard' },
{ name: 'Solicitar Evento', path: 'request-event' },
{ name: 'Álbuns Entregues', path: 'albums' }
{ name: 'Solicitar Evento', path: 'request-event' }
];
case UserRole.PHOTOGRAPHER:
return [
{ name: 'Eventos Designados', path: 'dashboard' },
{ name: 'Meus Uploads', path: 'uploads' },
{ name: 'Agenda', path: 'calendar' }
];
default:

View file

@ -1,6 +1,6 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
import { EventData, EventStatus, EventType, Attachment, Institution } from '../types';
import { EventData, EventStatus, EventType, Institution } from '../types';
// Initial Mock Data
const INITIAL_INSTITUTIONS: Institution[] = [
@ -41,10 +41,6 @@ const INITIAL_EVENTS: EventData[] = [
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'],
institutionId: 'inst-1'
@ -67,7 +63,6 @@ const INITIAL_EVENTS: EventData[] = [
coverImage: 'https://picsum.photos/id/3/800/400',
contacts: [],
checklist: [],
attachments: [],
ownerId: 'client-2', // Other client
photographerIds: []
}
@ -80,7 +75,6 @@ interface DataContextType {
updateEventStatus: (id: string, status: EventStatus) => void;
assignPhotographer: (eventId: string, photographerId: string) => void;
getEventsByRole: (userId: string, role: string) => EventData[];
addAttachment: (eventId: string, attachment: Attachment) => void;
addInstitution: (institution: Institution) => void;
updateInstitution: (id: string, institution: Partial<Institution>) => void;
getInstitutionsByUserId: (userId: string) => Institution[];
@ -126,15 +120,6 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
return [];
};
const addAttachment = (eventId: string, attachment: Attachment) => {
setEvents(prev => prev.map(e => {
if (e.id === eventId) {
return { ...e, attachments: [...e.attachments, attachment] };
}
return e;
}));
};
const addInstitution = (institution: Institution) => {
setInstitutions(prev => [...prev, institution]);
};
@ -161,7 +146,6 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
updateEventStatus,
assignPhotographer,
getEventsByRole,
addAttachment,
addInstitution,
updateInstitution,
getInstitutionsByUserId,

View file

@ -4,7 +4,11 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PhotumFormaturas - Gestão de Eventos</title>
<title>PhotumFormaturas</title>
<!-- Mapbox GL CSS (versão CDN confiável) -->
<link href='https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css' rel='stylesheet' />
<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"
@ -180,6 +184,45 @@
transform: translateY(0);
}
}
/* Mapbox custom styles */
.mapboxgl-ctrl-group {
border-radius: 8px !important;
box-shadow: 0 2px 8px rgba(0,0,0,0.15) !important;
}
.mapboxgl-ctrl-group button {
width: 36px !important;
height: 36px !important;
}
.mapboxgl-ctrl-geolocate {
background-color: white !important;
}
.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='%23333' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 2v20M2 12h20'/%3E%3Ccircle cx='12' cy='12' r='4'/%3E%3C/svg%3E") !important;
}
.custom-marker {
cursor: grab !important;
transition: transform 0.2s ease;
}
.custom-marker:active {
cursor: grabbing !important;
transform: scale(1.1);
}
.mapboxgl-popup {
max-width: 300px !important;
}
.mapboxgl-popup-content {
border-radius: 8px !important;
padding: 15px !important;
box-shadow: 0 4px 12px rgba(0,0,0,0.15) !important;
}
</style>
</head>

278
package-lock.json generated
View file

@ -9,7 +9,9 @@
"version": "0.0.0",
"dependencies": {
"@google/genai": "^1.30.0",
"@types/mapbox-gl": "^3.4.1",
"lucide-react": "^0.554.0",
"mapbox-gl": "^3.16.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.6"
@ -834,6 +836,58 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
"integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@mapbox/mapbox-gl-supported": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-3.0.0.tgz",
"integrity": "sha512-2XghOwu16ZwPJLOFVuIOaLbN0iKMn867evzXFyf0P22dqugezfJwLmdanAgU25ITvz1TvOfVP4jsDImlDJzcWg==",
"license": "BSD-3-Clause"
},
"node_modules/@mapbox/point-geometry": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
"license": "ISC"
},
"node_modules/@mapbox/tiny-sdf": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz",
"integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/unitbezier": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/vector-tile": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz",
"integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "~1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^4.0.1"
}
},
"node_modules/@mapbox/whoots-js": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@ -1211,6 +1265,36 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/geojson-vt": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz",
"integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/mapbox__point-geometry": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz",
"integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==",
"license": "MIT"
},
"node_modules/@types/mapbox-gl": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/@types/mapbox-gl/-/mapbox-gl-3.4.1.tgz",
"integrity": "sha512-NsGKKtgW93B+UaLPti6B7NwlxYlES5DpV5Gzj9F75rK5ALKsqSk15CiEHbOnTr09RGbr6ZYiCdI+59NNNcAImg==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/node": {
"version": "22.19.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz",
@ -1222,6 +1306,21 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/pbf": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz",
"integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==",
"license": "MIT"
},
"node_modules/@types/supercluster": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.1.tgz",
@ -1392,6 +1491,12 @@
],
"license": "CC-BY-4.0"
},
"node_modules/cheap-ruler": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cheap-ruler/-/cheap-ruler-4.0.0.tgz",
"integrity": "sha512-0BJa8f4t141BYKQyn9NSQt1PguFQXMXwZiA5shfoaBYHAb2fFk2RAX+tiWMoQU+Agtzt3mdt0JtuyshAXqZ+Vw==",
"license": "ISC"
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -1444,6 +1549,12 @@
"node": ">= 8"
}
},
"node_modules/csscolorparser": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz",
"integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==",
"license": "MIT"
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
@ -1470,6 +1581,12 @@
}
}
},
"node_modules/earcut": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
"license": "ISC"
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@ -1679,6 +1796,18 @@
"node": ">=6.9.0"
}
},
"node_modules/geojson-vt": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz",
"integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==",
"license": "ISC"
},
"node_modules/gl-matrix": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
"license": "MIT"
},
"node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
@ -1726,6 +1855,12 @@
"node": ">=14"
}
},
"node_modules/grid-index": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz",
"integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==",
"license": "ISC"
},
"node_modules/gtoken": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz",
@ -1845,6 +1980,12 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/kdbush": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
"license": "ISC"
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@ -1864,6 +2005,62 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/mapbox-gl": {
"version": "3.16.0",
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.16.0.tgz",
"integrity": "sha512-rluV1Zp/0oHf1Y9BV+nePRNnKyTdljko3E19CzO5rBqtQaNUYS0ePCMPRtxOuWRwSdKp3f9NWJkOCjemM8nmjw==",
"license": "SEE LICENSE IN LICENSE.txt",
"workspaces": [
"src/style-spec",
"test/build/typings"
],
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
"@mapbox/mapbox-gl-supported": "^3.0.0",
"@mapbox/point-geometry": "^1.1.0",
"@mapbox/tiny-sdf": "^2.0.6",
"@mapbox/unitbezier": "^0.0.1",
"@mapbox/vector-tile": "^2.0.4",
"@mapbox/whoots-js": "^3.1.0",
"@types/geojson": "^7946.0.16",
"@types/geojson-vt": "^3.2.5",
"@types/mapbox__point-geometry": "^0.1.4",
"@types/pbf": "^3.0.5",
"@types/supercluster": "^7.1.3",
"cheap-ruler": "^4.0.0",
"csscolorparser": "~1.0.3",
"earcut": "^3.0.1",
"geojson-vt": "^4.0.2",
"gl-matrix": "^3.4.4",
"grid-index": "^1.1.0",
"kdbush": "^4.0.2",
"martinez-polygon-clipping": "^0.7.4",
"murmurhash-js": "^1.0.0",
"pbf": "^4.0.1",
"potpack": "^2.0.0",
"quickselect": "^3.0.0",
"serialize-to-js": "^3.1.2",
"supercluster": "^8.0.1",
"tinyqueue": "^3.0.0"
}
},
"node_modules/martinez-polygon-clipping": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/martinez-polygon-clipping/-/martinez-polygon-clipping-0.7.4.tgz",
"integrity": "sha512-jBEwrKtA0jTagUZj2bnmb4Yg2s4KnJGRePStgI7bAVjtcipKiF39R4LZ2V/UT61jMYWrTcBhPazexeqd6JAVtw==",
"license": "MIT",
"dependencies": {
"robust-predicates": "^2.0.4",
"splaytree": "^0.1.4",
"tinyqueue": "^1.2.0"
}
},
"node_modules/martinez-polygon-clipping/node_modules/tinyqueue": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-1.2.3.tgz",
"integrity": "sha512-Qz9RgWuO9l8lT+Y9xvbzhPT2efIUIFd69N7eF7tJ9lnQl0iLj1M7peK7IoUGZL9DJHw9XftqLreccfxcQgYLxA==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
@ -1894,6 +2091,12 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/murmurhash-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@ -1995,6 +2198,18 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/pbf": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
"integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -2045,6 +2260,24 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/potpack": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
"license": "ISC"
},
"node_modules/protocol-buffers-schema": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
"integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==",
"license": "MIT"
},
"node_modules/quickselect": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
"license": "ISC"
},
"node_modules/react": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
@ -2116,6 +2349,15 @@
"react-dom": ">=18"
}
},
"node_modules/resolve-protobuf-schema": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
"license": "MIT",
"dependencies": {
"protocol-buffers-schema": "^3.3.1"
}
},
"node_modules/rimraf": {
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz",
@ -2131,6 +2373,12 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/robust-predicates": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-2.0.4.tgz",
"integrity": "sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg==",
"license": "Unlicense"
},
"node_modules/rollup": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
@ -2209,6 +2457,15 @@
"semver": "bin/semver.js"
}
},
"node_modules/serialize-to-js": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/serialize-to-js/-/serialize-to-js-3.1.2.tgz",
"integrity": "sha512-owllqNuDDEimQat7EPG0tH7JjO090xKNzUtYz6X+Sk2BXDnOCilDdNLwjWeFywG9xkJul1ULvtUQa9O4pUaY0w==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
@ -2258,6 +2515,12 @@
"node": ">=0.10.0"
}
},
"node_modules/splaytree": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/splaytree/-/splaytree-0.1.4.tgz",
"integrity": "sha512-D50hKrjZgBzqD3FT2Ek53f2dcDLAQT8SSGrzj3vidNH5ISRgceeGVJ2dQIthKOuayqFXfFjXheHNo4bbt9LhRQ==",
"license": "MIT"
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@ -2354,6 +2617,15 @@
"node": ">=8"
}
},
"node_modules/supercluster": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.0.2"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@ -2371,6 +2643,12 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyqueue": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
"license": "ISC"
},
"node_modules/typescript": {
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",

View file

@ -10,7 +10,9 @@
},
"dependencies": {
"@google/genai": "^1.30.0",
"@types/mapbox-gl": "^3.4.1",
"lucide-react": "^0.554.0",
"mapbox-gl": "^3.16.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.6"

View file

@ -1,190 +0,0 @@
import React, { useState } from 'react';
import { Search, Filter, Calendar, MapPin, Image as ImageIcon, ExternalLink, Download, Share2 } from 'lucide-react';
import { Button } from '../components/Button';
interface Album {
id: string;
eventName: string;
clientName: string;
date: string;
coverImage: string;
photoCount: number;
size: string;
status: 'delivered' | 'archived';
link: string;
}
const MOCK_ALBUMS: Album[] = [
{
id: '1',
eventName: 'Casamento Juliana & Marcos',
clientName: 'Juliana Noiva',
date: '2024-10-15',
coverImage: 'https://images.unsplash.com/photo-1511795409834-ef04bbd61622?ixlib=rb-1.2.1&auto=format&fit=crop&w=800&q=80',
photoCount: 450,
size: '2.4 GB',
status: 'delivered',
link: '#'
},
{
id: '2',
eventName: 'Formatura Medicina UFPR',
clientName: 'Comissão de Formatura',
date: '2024-09-20',
coverImage: 'https://images.unsplash.com/photo-1523580494863-6f3031224c94?ixlib=rb-1.2.1&auto=format&fit=crop&w=800&q=80',
photoCount: 1200,
size: '8.5 GB',
status: 'delivered',
link: '#'
},
{
id: '3',
eventName: 'Aniversário 15 Anos Sofia',
clientName: 'Ana Paula (Mãe)',
date: '2024-08-05',
coverImage: 'https://images.unsplash.com/photo-1530103862676-de3c9a59af57?ixlib=rb-1.2.1&auto=format&fit=crop&w=800&q=80',
photoCount: 320,
size: '1.8 GB',
status: 'archived',
link: '#'
},
{
id: '4',
eventName: 'Evento Corporativo TechSummit',
clientName: 'Tech Solutions Inc.',
date: '2024-11-01',
coverImage: 'https://images.unsplash.com/photo-1515187029135-18ee286d815b?ixlib=rb-1.2.1&auto=format&fit=crop&w=800&q=80',
photoCount: 580,
size: '3.1 GB',
status: 'delivered',
link: '#'
}
];
export const AlbumsPage: React.FC = () => {
const [searchTerm, setSearchTerm] = useState('');
const [filter, setFilter] = useState<'all' | 'delivered' | 'archived'>('all');
const filteredAlbums = MOCK_ALBUMS.filter(album => {
const matchesSearch = album.eventName.toLowerCase().includes(searchTerm.toLowerCase()) ||
album.clientName.toLowerCase().includes(searchTerm.toLowerCase());
const matchesFilter = filter === 'all' || album.status === filter;
return matchesSearch && matchesFilter;
});
return (
<div className="min-h-screen bg-gray-50 pt-24 pb-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto">
<div className="flex flex-col md:flex-row md:items-center justify-between mb-8 gap-4">
<div>
<h1 className="text-3xl font-serif font-bold text-brand-black">Álbuns Entregues</h1>
<p className="text-gray-500 mt-1">Gerencie e compartilhe os álbuns finalizados com seus clientes.</p>
</div>
{/* Placeholder for future actions if needed */}
</div>
{/* Filters & Search */}
<div className="bg-white p-4 rounded-lg shadow-sm border border-gray-100 mb-8 flex flex-col md:flex-row gap-4 items-center justify-between">
<div className="relative flex-1 w-full md:max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
<input
type="text"
placeholder="Buscar por evento ou cliente..."
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex gap-2 w-full md:w-auto overflow-x-auto pb-2 md:pb-0">
<button
onClick={() => setFilter('all')}
className={`px-4 py-2 rounded-md text-sm font-medium whitespace-nowrap transition-colors ${filter === 'all' ? 'bg-brand-black text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
Todos
</button>
<button
onClick={() => setFilter('delivered')}
className={`px-4 py-2 rounded-md text-sm font-medium whitespace-nowrap transition-colors ${filter === 'delivered' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
Entregues
</button>
<button
onClick={() => setFilter('archived')}
className={`px-4 py-2 rounded-md text-sm font-medium whitespace-nowrap transition-colors ${filter === 'archived' ? 'bg-gray-200 text-gray-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
Arquivados
</button>
</div>
</div>
{/* Albums Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{filteredAlbums.map((album) => (
<div key={album.id} className="bg-white rounded-lg shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-shadow group">
<div className="relative h-48 overflow-hidden">
<img
src={album.coverImage}
alt={album.eventName}
className="w-full h-full object-cover transform group-hover:scale-105 transition-transform duration-500"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-80"></div>
<div className="absolute bottom-4 left-4 text-white">
<h3 className="font-bold text-lg leading-tight mb-1">{album.eventName}</h3>
<p className="text-xs opacity-90 flex items-center">
<Calendar size={12} className="mr-1" /> {new Date(album.date).toLocaleDateString()}
</p>
</div>
<div className="absolute top-4 right-4">
<span className={`px-2 py-1 rounded text-xs font-bold uppercase tracking-wide ${album.status === 'delivered' ? 'bg-green-500 text-white' : 'bg-gray-500 text-white'
}`}>
{album.status === 'delivered' ? 'Entregue' : 'Arquivado'}
</span>
</div>
</div>
<div className="p-6">
<div className="flex justify-between items-start mb-4">
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide font-bold mb-1">Cliente</p>
<p className="text-sm font-medium text-gray-800">{album.clientName}</p>
</div>
<div className="text-right">
<p className="text-xs text-gray-500 uppercase tracking-wide font-bold mb-1">Fotos</p>
<p className="text-sm font-medium text-gray-800">{album.photoCount}</p>
</div>
</div>
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
<span className="text-xs text-gray-400 font-medium">{album.size}</span>
<div className="flex gap-2">
<button className="p-2 text-gray-400 hover:text-brand-gold hover:bg-yellow-50 rounded-full transition-colors" title="Download">
<Download size={18} />
</button>
<button className="p-2 text-gray-400 hover:text-blue-500 hover:bg-blue-50 rounded-full transition-colors" title="Compartilhar Link">
<Share2 size={18} />
</button>
<Button size="sm" variant="outline" className="ml-2">
Ver Álbum <ExternalLink size={14} className="ml-1" />
</Button>
</div>
</div>
</div>
</div>
))}
</div>
{filteredAlbums.length === 0 && (
<div className="text-center py-20 bg-white rounded-lg border border-dashed border-gray-200">
<ImageIcon className="mx-auto h-12 w-12 text-gray-300 mb-4" />
<h3 className="text-lg font-medium text-gray-900">Nenhum álbum encontrado</h3>
<p className="text-gray-500">Tente ajustar seus filtros de busca.</p>
</div>
)}
</div>
</div>
);
};

View file

@ -4,19 +4,19 @@ 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, Building2 } from 'lucide-react';
import { PlusCircle, Search, CheckCircle, Clock, Edit, Users, Map, Building2 } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { useData } from '../contexts/DataContext';
import { STATUS_COLORS } from '../constants';
interface DashboardProps {
initialView?: 'list' | 'create' | 'uploads';
initialView?: 'list' | 'create';
}
export const Dashboard: React.FC<DashboardProps> = ({ initialView = 'list' }) => {
const { user } = useAuth();
const { events, getEventsByRole, addEvent, updateEventStatus, assignPhotographer, addAttachment, getInstitutionById } = useData();
const [view, setView] = useState<'list' | 'create' | 'edit' | 'details' | 'uploads'>(initialView);
const { events, getEventsByRole, addEvent, updateEventStatus, assignPhotographer, getInstitutionById } = useData();
const [view, setView] = useState<'list' | 'create' | 'edit' | 'details'>(initialView);
const [searchTerm, setSearchTerm] = useState('');
const [selectedEvent, setSelectedEvent] = useState<EventData | null>(null);
const [activeFilter, setActiveFilter] = useState<string>('all');
@ -58,7 +58,6 @@ export const Dashboard: React.FC<DashboardProps> = ({ initialView = 'list' }) =>
id: Math.random().toString(36).substr(2, 9),
status: initialStatus,
checklist: [],
attachments: [],
ownerId: isClient ? user.id : 'unknown',
photographerIds: []
};
@ -94,24 +93,6 @@ export const Dashboard: React.FC<DashboardProps> = ({ initialView = 'list' }) =>
}
};
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 = () => {
@ -127,7 +108,7 @@ export const Dashboard: React.FC<DashboardProps> = ({ initialView = 'list' }) =>
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>
<p className="text-gray-500 mt-1">Gerencie seus trabalhos e visualize detalhes.</p>
</div>
);
}
@ -276,11 +257,6 @@ export const Dashboard: React.FC<DashboardProps> = ({ initialView = 'list' }) =>
<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')}>
@ -414,94 +390,6 @@ export const Dashboard: React.FC<DashboardProps> = ({ initialView = 'list' }) =>
</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>
);

181
pages/Inspiration.tsx Normal file
View file

@ -0,0 +1,181 @@
import React, { useState } from 'react';
import { Heart, Search, Filter } from 'lucide-react';
import { Construction } from 'lucide-react';
const MOCK_GALLERIES = [
{
id: 1,
title: 'Formatura Medicina UNICAMP 2024',
category: 'Medicina',
images: [
'https://images.unsplash.com/photo-1523050854058-8df90110c9f1?w=800',
'https://images.unsplash.com/photo-1541339907198-e08756dedf3f?w=800',
'https://images.unsplash.com/photo-1523240795612-9a054b0db644?w=800',
],
likes: 234,
},
{
id: 2,
title: 'Engenharia Civil - USP 2024',
category: 'Engenharia',
images: [
'https://images.unsplash.com/photo-1513542789411-b6a5d4f31634?w=800',
'https://images.unsplash.com/photo-1511632765486-a01980e01a18?w=800',
'https://images.unsplash.com/photo-1523050854058-8df90110c9f1?w=800',
],
likes: 189,
},
{
id: 3,
title: 'Direito PUC 2023',
category: 'Direito',
images: [
'https://images.unsplash.com/photo-1519389950473-47ba0277781c?w=800',
'https://images.unsplash.com/photo-1521737711867-e3b97375f902?w=800',
'https://images.unsplash.com/photo-1522202176988-66273c2fd55f?w=800',
],
likes: 312,
},
{
id: 4,
title: 'Arquitetura UNESP 2024',
category: 'Arquitetura',
images: [
'https://images.unsplash.com/photo-1528605248644-14dd04022da1?w=800',
'https://images.unsplash.com/photo-1523050854058-8df90110c9f1?w=800',
'https://images.unsplash.com/photo-1519337265831-281ec6cc8514?w=800',
],
likes: 278,
},
];
const CATEGORIES = ['Todas', 'Medicina', 'Engenharia', 'Direito', 'Arquitetura', 'Administração'];
export const InspirationPage: React.FC = () => {
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState('Todas');
const filteredGalleries = MOCK_GALLERIES.filter((gallery) => {
const matchesSearch = gallery.title.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = selectedCategory === 'Todas' || gallery.category === selectedCategory;
return matchesSearch && matchesCategory;
});
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 pt-24 pb-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="text-center mb-12 fade-in">
<h1 className="text-4xl sm:text-5xl font-serif font-bold text-brand-black mb-4">
Galeria de Inspiração
</h1>
<p className="text-gray-600 text-lg max-w-2xl mx-auto">
Explore álbuns de formaturas anteriores e inspire-se para criar o seu evento perfeito
</p>
</div>
{/* Search and Filter */}
<div className="mb-8 space-y-4">
{/* Search Bar */}
<div className="relative max-w-2xl mx-auto">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-gray-400 h-5 w-5" />
<input
type="text"
placeholder="Buscar por curso, universidade..."
className="w-full pl-12 pr-4 py-3 bg-white border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-gold focus:border-transparent text-sm shadow-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{/* Category Filter */}
<div className="flex flex-wrap justify-center gap-2">
{CATEGORIES.map((category) => (
<button
key={category}
onClick={() => setSelectedCategory(category)}
className={`px-4 py-2 rounded-full text-sm font-medium transition-all ${
selectedCategory === category
? 'bg-brand-gold text-white shadow-lg scale-105'
: 'bg-white text-gray-700 hover:bg-gray-50 border border-gray-200'
}`}
>
{category}
</button>
))}
</div>
</div>
{/* Gallery Grid */}
{filteredGalleries.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredGalleries.map((gallery) => (
<div
key={gallery.id}
className="bg-white rounded-xl overflow-hidden shadow-md hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2 group"
>
{/* Main Image */}
<div className="relative h-64 overflow-hidden">
<img
src={gallery.images[0]}
alt={gallery.title}
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
{/* Category Badge */}
<div className="absolute top-3 left-3 bg-white/95 backdrop-blur-sm px-3 py-1 rounded-full">
<span className="text-xs font-semibold text-brand-gold">{gallery.category}</span>
</div>
</div>
{/* Content */}
<div className="p-5">
<h3 className="text-lg font-bold text-brand-black mb-2 line-clamp-1">
{gallery.title}
</h3>
{/* Thumbnail Preview */}
<div className="flex gap-2 mb-3">
{gallery.images.slice(1, 3).map((img, idx) => (
<div key={idx} className="w-16 h-16 rounded-lg overflow-hidden border border-gray-200">
<img src={img} alt="" className="w-full h-full object-cover" />
</div>
))}
<div className="w-16 h-16 rounded-lg bg-gray-100 flex items-center justify-center text-xs text-gray-500 font-medium border border-gray-200">
+12
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between pt-3 border-t border-gray-100">
<div className="flex items-center gap-2 text-gray-500">
<Heart size={16} className="text-red-400" />
<span className="text-sm font-medium">{gallery.likes} curtidas</span>
</div>
<button className="text-sm text-brand-gold font-semibold hover:underline">
Ver álbum
</button>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-16">
<p className="text-gray-500 text-lg">Nenhuma galeria encontrada</p>
</div>
)}
{/* Coming Soon Banner */}
<div className="mt-16 bg-gradient-to-r from-brand-purple to-brand-gold/20 rounded-2xl p-8 text-center">
<Construction className="mx-auto text-white mb-4" size={48} />
<h2 className="text-2xl font-bold text-white mb-2">Em Breve: Mais Funcionalidades</h2>
<p className="text-white/90 max-w-2xl mx-auto">
Estamos trabalhando para trazer mais galerias, filtros avançados e a possibilidade de salvar seus favoritos!
</p>
</div>
</div>
</div>
);
};

View file

@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Users, Camera, Mail, Phone, MapPin, Star, Plus, Search, Filter, User } from 'lucide-react';
import { Users, Camera, Mail, Phone, MapPin, Star, Plus, Search, Filter, User, Upload, X } from 'lucide-react';
import { Button } from '../components/Button';
interface Photographer {
@ -108,7 +108,27 @@ export const TeamPage: React.FC = () => {
phone: '',
location: '',
specialties: [] as string[],
avatar: ''
});
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string>('');
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setAvatarFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setAvatarPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const removeAvatar = () => {
setAvatarFile(null);
setAvatarPreview('');
};
const getStatusColor = (status: Photographer['status']) => {
switch (status) {
@ -340,16 +360,67 @@ export const TeamPage: React.FC = () => {
<form className="space-y-6" onSubmit={(e) => {
e.preventDefault();
alert('Fotógrafo adicionado com sucesso!\n\n' + JSON.stringify(newPhotographer, null, 2));
alert('Fotógrafo adicionado com sucesso!\n\n' + JSON.stringify({...newPhotographer, avatarFile: avatarFile?.name}, null, 2));
setShowAddModal(false);
setNewPhotographer({
name: '',
email: '',
phone: '',
location: '',
specialties: []
specialties: [],
avatar: ''
});
setAvatarFile(null);
setAvatarPreview('');
}}>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Foto de Perfil
</label>
<div className="flex items-center gap-4">
{avatarPreview ? (
<div className="relative">
<img
src={avatarPreview}
alt="Preview"
className="w-24 h-24 rounded-full object-cover border-2 border-gray-200"
/>
<button
type="button"
onClick={removeAvatar}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 hover:bg-red-600 transition-colors"
>
<X size={16} />
</button>
</div>
) : (
<div className="w-24 h-24 rounded-full bg-gray-100 border-2 border-dashed border-gray-300 flex items-center justify-center">
<Camera size={32} className="text-gray-400" />
</div>
)}
<div className="flex-1">
<label className="cursor-pointer">
<div className="flex items-center gap-2 px-4 py-2 bg-gray-50 border border-gray-300 rounded-md hover:bg-gray-100 transition-colors w-fit">
<Upload size={18} className="text-gray-600" />
<span className="text-sm font-medium text-gray-700">
{avatarFile ? 'Trocar foto' : 'Selecionar foto'}
</span>
</div>
<input
type="file"
accept="image/*"
onChange={handleAvatarChange}
className="hidden"
/>
</label>
<p className="text-xs text-gray-500 mt-1">JPG, PNG ou GIF (máx. 5MB)</p>
{avatarFile && (
<p className="text-xs text-brand-gold mt-1 font-medium">{avatarFile.name}</p>
)}
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Nome Completo *

149
services/mapboxService.ts Normal file
View file

@ -0,0 +1,149 @@
// Mapbox Geocoding Service
// Docs: https://docs.mapbox.com/api/search/geocoding/
export interface MapboxFeature {
id: string;
place_name: string;
center: [number, number]; // [longitude, latitude]
geometry: {
coordinates: [number, number];
};
context?: Array<{
id: string;
text: string;
short_code?: string;
}>;
place_type: string[];
text: string;
address?: string;
}
export interface MapboxResult {
description: string;
street: string;
number: string;
city: string;
state: string;
zip: string;
lat: number;
lng: number;
mapLink: string;
}
// Token do Mapbox configurado no arquivo .env.local
const MAPBOX_TOKEN = import.meta.env.VITE_MAPBOX_TOKEN || '';
/**
* Busca endereços usando a API de Geocoding do Mapbox
* @param query - Texto de busca (endereço, local, etc)
* @param country - Código do país (ex: 'br' para Brasil)
*/
export async function searchMapboxLocation(
query: string,
country: string = 'br'
): Promise<MapboxResult[]> {
if (!MAPBOX_TOKEN || MAPBOX_TOKEN.startsWith('YOUR_')) {
console.warn('⚠️ Mapbox Token não configurado. Configure em services/mapboxService.ts');
return [];
}
try {
const encodedQuery = encodeURIComponent(query);
const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodedQuery}.json?` +
`access_token=${MAPBOX_TOKEN}&` +
`country=${country}&` +
`language=pt&` +
`limit=5`;
console.log('🔍 Buscando endereço:', query);
const response = await fetch(url);
if (!response.ok) {
console.error('❌ Erro na API Mapbox:', response.statusText);
throw new Error(`Mapbox API error: ${response.statusText}`);
}
const data = await response.json();
console.log('✅ Resultados encontrados:', data.features?.length || 0);
if (!data.features || data.features.length === 0) {
return [];
}
return data.features.map((feature: MapboxFeature) => {
// Extrair informações do contexto
const context = feature.context || [];
const place = context.find(c => c.id.startsWith('place'));
const region = context.find(c => c.id.startsWith('region'));
const postcode = context.find(c => c.id.startsWith('postcode'));
// Extrair número do endereço
const addressMatch = feature.address || feature.text.match(/\d+/)?.[0] || '';
return {
description: feature.place_name,
street: feature.text,
number: addressMatch,
city: place?.text || '',
state: region?.short_code?.replace('BR-', '') || region?.text || '',
zip: postcode?.text || '',
lat: feature.center[1],
lng: feature.center[0],
mapLink: `https://www.google.com/maps/search/?api=1&query=${feature.center[1]},${feature.center[0]}`
};
});
} catch (error) {
console.error('Erro ao buscar localização no Mapbox:', error);
return [];
}
}
/**
* Busca reversa: converte coordenadas em endereço
* Retorna o endereço completo baseado nas coordenadas do pin
*/
export async function reverseGeocode(lat: number, lng: number): Promise<MapboxResult | null> {
if (!MAPBOX_TOKEN || MAPBOX_TOKEN.startsWith('YOUR_')) {
console.warn('⚠️ Mapbox Token não configurado');
return null;
}
try {
const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${lng},${lat}.json?` +
`access_token=${MAPBOX_TOKEN}&` +
`language=pt&` +
`types=address,place`;
const response = await fetch(url);
const data = await response.json();
if (data.features && data.features.length > 0) {
const feature = data.features[0];
const context = feature.context || [];
const place = context.find((c: any) => c.id.startsWith('place'));
const region = context.find((c: any) => c.id.startsWith('region'));
const postcode = context.find((c: any) => c.id.startsWith('postcode'));
// Extrair número se houver
const addressMatch = feature.address || '';
return {
description: feature.place_name,
street: feature.text,
number: addressMatch.toString(),
city: place?.text || '',
state: region?.short_code?.replace('BR-', '') || region?.text || '',
zip: postcode?.text || '',
lat,
lng,
mapLink: `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`
};
}
return null;
} catch (error) {
console.error('Erro no reverse geocode:', error);
return null;
}
}

View file

@ -70,13 +70,6 @@ export interface ChecklistItem {
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;
@ -89,7 +82,6 @@ export interface EventData {
checklist: ChecklistItem[];
briefing: string;
coverImage: string;
attachments: Attachment[];
ownerId: string; // ID do cliente dono do evento
photographerIds: string[]; // IDs dos fotógrafos designados
institutionId?: string; // ID da instituição vinculada (obrigatório)

9
vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_MAPBOX_TOKEN: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}