Vendaweb-portal/components/checkout/AddressFormModal.tsx

693 lines
26 KiB
TypeScript
Raw Normal View History

2026-01-08 12:09:16 +00:00
import React, { useState, useEffect, useRef } from "react";
import { X, MapPin, Save, Search } from "lucide-react";
import { CustomerAddress, customerService } from "../../src/services/customer.service";
interface AddressFormModalProps {
isOpen: boolean;
customerId: number | null;
address?: CustomerAddress | null;
onClose: () => void;
onSave: (address: CustomerAddress) => void;
}
declare global {
interface Window {
google: any;
initMap: () => void;
}
}
const AddressFormModal: React.FC<AddressFormModalProps> = ({
isOpen,
customerId,
address,
onClose,
onSave,
}) => {
const [formData, setFormData] = useState({
zipCode: "",
address: "",
number: "",
complement: "",
neighborhood: "",
city: "",
state: "",
referencePoint: "",
note: "",
addressType: "Casa",
isPrimary: false,
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isLoading, setIsLoading] = useState(false);
const [mapLoaded, setMapLoaded] = useState(false);
const [map, setMap] = useState<any>(null);
const [marker, setMarker] = useState<any>(null);
const [geocoder, setGeocoder] = useState<any>(null);
const mapRef = useRef<HTMLDivElement>(null);
const [coordinates, setCoordinates] = useState<{ lat: number; lng: number } | null>(null);
useEffect(() => {
if (isOpen) {
if (address) {
setFormData({
zipCode: address.zipCode || "",
address: address.address || "",
number: address.number || "",
complement: address.complement || "",
neighborhood: address.neighborhood || "",
city: address.city || "",
state: address.state || "",
referencePoint: address.referencePoint || "",
note: address.note || "",
addressType: address.addressType || "Casa",
isPrimary: address.isPrimary || false,
});
if (address.latitude && address.longitude) {
setCoordinates({ lat: address.latitude, lng: address.longitude });
}
} else {
setFormData({
zipCode: "",
address: "",
number: "",
complement: "",
neighborhood: "",
city: "",
state: "",
referencePoint: "",
note: "",
addressType: "Casa",
isPrimary: false,
});
setCoordinates(null);
}
loadGoogleMaps();
}
}, [isOpen, address]);
const loadGoogleMaps = () => {
if (window.google && window.google.maps) {
initializeMap();
return;
}
if (!document.querySelector('script[src*="maps.googleapis.com"]')) {
const script = document.createElement("script");
const apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY || "";
if (apiKey) {
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places&callback=initMap`;
script.async = true;
script.defer = true;
window.initMap = initializeMap;
document.head.appendChild(script);
} else {
console.warn("Google Maps API Key não configurada. Configure VITE_GOOGLE_MAPS_API_KEY no arquivo .env");
}
} else {
initializeMap();
}
};
const initializeMap = () => {
if (!mapRef.current || !window.google) return;
const initialCenter = coordinates || { lat: -23.5505, lng: -46.6333 }; // São Paulo default
const mapInstance = new window.google.maps.Map(mapRef.current, {
center: initialCenter,
zoom: coordinates ? 15 : 10,
mapTypeControl: false,
streetViewControl: false,
fullscreenControl: false,
});
const geocoderInstance = new window.google.maps.Geocoder();
setGeocoder(geocoderInstance);
setMap(mapInstance);
let markerInstance: any = null;
if (coordinates) {
markerInstance = new window.google.maps.Marker({
position: coordinates,
map: mapInstance,
draggable: true,
animation: window.google.maps.Animation.DROP,
});
markerInstance.addListener("dragend", (e: any) => {
const newPosition = {
lat: e.latLng.lat(),
lng: e.latLng.lng(),
};
setCoordinates(newPosition);
reverseGeocode(newPosition);
});
} else {
// Adiciona marcador no centro inicial
markerInstance = new window.google.maps.Marker({
position: initialCenter,
map: mapInstance,
draggable: true,
animation: window.google.maps.Animation.DROP,
});
markerInstance.addListener("dragend", (e: any) => {
const newPosition = {
lat: e.latLng.lat(),
lng: e.latLng.lng(),
};
setCoordinates(newPosition);
reverseGeocode(newPosition);
});
}
setMarker(markerInstance);
// Click no mapa para adicionar/mover marcador
mapInstance.addListener("click", (e: any) => {
const newPosition = {
lat: e.latLng.lat(),
lng: e.latLng.lng(),
};
setCoordinates(newPosition);
if (markerInstance) {
markerInstance.setPosition(newPosition);
} else {
markerInstance = new window.google.maps.Marker({
position: newPosition,
map: mapInstance,
draggable: true,
});
markerInstance.addListener("dragend", (e: any) => {
const newPos = {
lat: e.latLng.lat(),
lng: e.latLng.lng(),
};
setCoordinates(newPos);
reverseGeocode(newPos);
});
setMarker(markerInstance);
}
reverseGeocode(newPosition);
});
// Autocomplete para busca de endereço
const autocomplete = new window.google.maps.places.Autocomplete(
document.getElementById("address-search") as HTMLInputElement,
{ types: ["address"] }
);
autocomplete.addListener("place_changed", () => {
const place = autocomplete.getPlace();
if (place.geometry) {
const location = place.geometry.location;
const newPosition = {
lat: location.lat(),
lng: location.lng(),
};
setCoordinates(newPosition);
mapInstance.setCenter(newPosition);
mapInstance.setZoom(15);
if (markerInstance) {
markerInstance.setPosition(newPosition);
}
// Preencher campos do formulário
const addressComponents = place.address_components || [];
addressComponents.forEach((component: any) => {
const type = component.types[0];
if (type === "street_number") {
setFormData((prev) => ({ ...prev, number: component.long_name }));
} else if (type === "route") {
setFormData((prev) => ({ ...prev, address: component.long_name }));
} else if (type === "sublocality_level_1" || type === "neighborhood") {
setFormData((prev) => ({ ...prev, neighborhood: component.long_name }));
} else if (type === "locality") {
setFormData((prev) => ({ ...prev, city: component.long_name }));
} else if (type === "administrative_area_level_1") {
setFormData((prev) => ({ ...prev, state: component.short_name }));
} else if (type === "postal_code") {
setFormData((prev) => ({ ...prev, zipCode: component.long_name }));
}
});
if (place.formatted_address) {
setFormData((prev) => ({ ...prev, address: place.formatted_address.split(",")[0] }));
}
}
});
setMapLoaded(true);
};
const reverseGeocode = (position: { lat: number; lng: number }) => {
if (!geocoder) return;
geocoder.geocode({ location: position }, (results: any[], status: string) => {
if (status === "OK" && results[0]) {
const result = results[0];
const addressComponents = result.address_components || [];
addressComponents.forEach((component: any) => {
const type = component.types[0];
if (type === "street_number") {
setFormData((prev) => ({ ...prev, number: component.long_name }));
} else if (type === "route") {
setFormData((prev) => ({ ...prev, address: component.long_name }));
} else if (type === "sublocality_level_1" || type === "neighborhood") {
setFormData((prev) => ({ ...prev, neighborhood: component.long_name }));
} else if (type === "locality") {
setFormData((prev) => ({ ...prev, city: component.long_name }));
} else if (type === "administrative_area_level_1") {
setFormData((prev) => ({ ...prev, state: component.short_name }));
} else if (type === "postal_code") {
setFormData((prev) => ({ ...prev, zipCode: component.long_name }));
}
});
}
});
};
const handleSearchByCEP = async () => {
if (!formData.zipCode || formData.zipCode.length < 8) return;
const cep = formData.zipCode.replace(/\D/g, "");
if (cep.length !== 8) return;
try {
const response = await fetch(`https://viacep.com.br/ws/${cep}/json/`);
const data = await response.json();
if (!data.erro) {
setFormData((prev) => ({
...prev,
address: data.logradouro || "",
neighborhood: data.bairro || "",
city: data.localidade || "",
state: data.uf || "",
}));
// Geocodificar o endereço completo
if (geocoder && data.logradouro) {
const fullAddress = `${data.logradouro}, ${data.localidade}, ${data.uf}, Brasil`;
geocoder.geocode({ address: fullAddress }, (results: any[], status: string) => {
if (status === "OK" && results[0]) {
const location = results[0].geometry.location;
const newPosition = {
lat: location.lat(),
lng: location.lng(),
};
setCoordinates(newPosition);
if (map) {
map.setCenter(newPosition);
map.setZoom(15);
}
if (marker) {
marker.setPosition(newPosition);
} else if (map) {
const newMarker = new window.google.maps.Marker({
position: newPosition,
map: map,
draggable: true,
});
newMarker.addListener("dragend", (e: any) => {
const newPos = {
lat: e.latLng.lat(),
lng: e.latLng.lng(),
};
setCoordinates(newPos);
reverseGeocode(newPos);
});
setMarker(newMarker);
}
}
});
}
}
} catch (error) {
console.error("Erro ao buscar CEP:", error);
}
};
const handleChange = (field: string, value: string | boolean) => {
setFormData((prev) => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[field];
return newErrors;
});
}
};
const validate = () => {
const newErrors: Record<string, string> = {};
if (!formData.zipCode) newErrors.zipCode = "CEP é obrigatório";
if (!formData.address) newErrors.address = "Endereço é obrigatório";
if (!formData.number) newErrors.number = "Número é obrigatório";
if (!formData.city) newErrors.city = "Cidade é obrigatória";
if (!formData.state) newErrors.state = "Estado é obrigatório";
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSave = async () => {
if (!validate() || !customerId) return;
setIsLoading(true);
try {
const addressData: Partial<CustomerAddress> = {
...formData,
latitude: coordinates?.lat,
longitude: coordinates?.lng,
};
let savedAddress: CustomerAddress | null;
if (address?.id) {
// Atualizar endereço existente
// TODO: Implementar updateAddress no service
savedAddress = await customerService.createAddress(customerId, addressData);
} else {
// Criar novo endereço
savedAddress = await customerService.createAddress(customerId, addressData);
}
if (savedAddress) {
onSave(savedAddress);
onClose();
}
} catch (error: any) {
console.error("Erro ao salvar endereço:", error);
setErrors({ general: error.message || "Erro ao salvar endereço" });
} finally {
setIsLoading(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-6xl max-h-[90vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="p-6 bg-[#002147] text-white rounded-t-3xl relative overflow-hidden flex-shrink-0">
<div className="relative z-10 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-orange-500/20 rounded-2xl flex items-center justify-center">
<MapPin className="w-6 h-6 text-orange-400" />
</div>
<div>
<h3 className="text-xl font-black">
{address ? "Editar Endereço" : "Cadastrar Novo Endereço"}
</h3>
<p className="text-xs text-orange-400 font-bold uppercase tracking-wider mt-0.5">
{address ? "Atualize os dados do endereço" : "Preencha os dados e selecione no mapa"}
</p>
</div>
</div>
<button
onClick={onClose}
className="w-10 h-10 flex items-center justify-center rounded-xl hover:bg-white/10 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="absolute right-[-10%] top-[-10%] w-32 h-32 bg-orange-400/10 rounded-full blur-2xl"></div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Formulário */}
<div className="space-y-4">
<h4 className="text-sm font-black uppercase text-slate-600 mb-4">
Dados do Endereço
</h4>
{/* Busca por CEP ou Endereço */}
<div>
<label className="block text-xs font-black uppercase text-slate-400 mb-2">
Buscar por CEP ou Endereço
</label>
<div className="flex gap-2">
<input
id="address-search"
type="text"
placeholder="Digite o CEP ou endereço..."
className="flex-1 px-4 py-3 bg-white border-2 border-slate-200 rounded-xl font-bold text-slate-700 outline-none focus:border-orange-500 transition-all"
/>
<button
onClick={handleSearchByCEP}
className="px-4 py-3 bg-[#002147] text-white rounded-xl font-bold hover:bg-[#001a36] transition-colors"
>
<Search className="w-5 h-5" />
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-black uppercase text-slate-400 mb-2">
CEP *
</label>
<input
type="text"
value={formData.zipCode}
onChange={(e) => handleChange("zipCode", e.target.value)}
className={`w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border ${
errors.zipCode ? "border-red-500" : "border-slate-200"
} focus:outline-none focus:ring-2 focus:ring-orange-500/20`}
placeholder="00000-000"
/>
{errors.zipCode && (
<p className="text-red-500 text-xs mt-1">{errors.zipCode}</p>
)}
</div>
<div>
<label className="block text-xs font-black uppercase text-slate-400 mb-2">
Tipo
</label>
<select
value={formData.addressType}
onChange={(e) => handleChange("addressType", e.target.value)}
className="w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border border-slate-200 focus:outline-none focus:ring-2 focus:ring-orange-500/20"
>
<option value="Casa">Casa</option>
<option value="Trabalho">Trabalho</option>
<option value="Outro">Outro</option>
</select>
</div>
</div>
<div>
<label className="block text-xs font-black uppercase text-slate-400 mb-2">
Endereço *
</label>
<input
type="text"
value={formData.address}
onChange={(e) => handleChange("address", e.target.value)}
className={`w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border ${
errors.address ? "border-red-500" : "border-slate-200"
} focus:outline-none focus:ring-2 focus:ring-orange-500/20`}
/>
{errors.address && (
<p className="text-red-500 text-xs mt-1">{errors.address}</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-black uppercase text-slate-400 mb-2">
Número *
</label>
<input
type="text"
value={formData.number}
onChange={(e) => handleChange("number", e.target.value)}
className={`w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border ${
errors.number ? "border-red-500" : "border-slate-200"
} focus:outline-none focus:ring-2 focus:ring-orange-500/20`}
/>
{errors.number && (
<p className="text-red-500 text-xs mt-1">{errors.number}</p>
)}
</div>
<div>
<label className="block text-xs font-black uppercase text-slate-400 mb-2">
Complemento
</label>
<input
type="text"
value={formData.complement}
onChange={(e) => handleChange("complement", e.target.value)}
className="w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border border-slate-200 focus:outline-none focus:ring-2 focus:ring-orange-500/20"
/>
</div>
</div>
<div>
<label className="block text-xs font-black uppercase text-slate-400 mb-2">
Bairro
</label>
<input
type="text"
value={formData.neighborhood}
onChange={(e) => handleChange("neighborhood", e.target.value)}
className="w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border border-slate-200 focus:outline-none focus:ring-2 focus:ring-orange-500/20"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-black uppercase text-slate-400 mb-2">
Cidade *
</label>
<input
type="text"
value={formData.city}
onChange={(e) => handleChange("city", e.target.value)}
className={`w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border ${
errors.city ? "border-red-500" : "border-slate-200"
} focus:outline-none focus:ring-2 focus:ring-orange-500/20`}
/>
{errors.city && (
<p className="text-red-500 text-xs mt-1">{errors.city}</p>
)}
</div>
<div>
<label className="block text-xs font-black uppercase text-slate-400 mb-2">
Estado *
</label>
<input
type="text"
value={formData.state}
onChange={(e) => handleChange("state", e.target.value)}
className={`w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border ${
errors.state ? "border-red-500" : "border-slate-200"
} focus:outline-none focus:ring-2 focus:ring-orange-500/20`}
maxLength={2}
placeholder="SP"
/>
{errors.state && (
<p className="text-red-500 text-xs mt-1">{errors.state}</p>
)}
</div>
</div>
<div>
<label className="block text-xs font-black uppercase text-slate-400 mb-2">
Ponto de Referência
</label>
<input
type="text"
value={formData.referencePoint}
onChange={(e) => handleChange("referencePoint", e.target.value)}
className="w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border border-slate-200 focus:outline-none focus:ring-2 focus:ring-orange-500/20"
/>
</div>
<div>
<label className="block text-xs font-black uppercase text-slate-400 mb-2">
Observações
</label>
<textarea
value={formData.note}
onChange={(e) => handleChange("note", e.target.value)}
rows={3}
className="w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border border-slate-200 focus:outline-none focus:ring-2 focus:ring-orange-500/20 resize-none"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isPrimary"
checked={formData.isPrimary}
onChange={(e) => handleChange("isPrimary", e.target.checked)}
className="w-5 h-5 rounded border-slate-300 text-[#002147] focus:ring-2 focus:ring-orange-500/20"
/>
<label htmlFor="isPrimary" className="text-sm font-bold text-slate-700">
Definir como endereço principal
</label>
</div>
{errors.general && (
<div className="p-3 bg-red-50 border border-red-200 rounded-xl">
<p className="text-red-600 text-sm font-bold">{errors.general}</p>
</div>
)}
</div>
{/* Mapa */}
<div>
<h4 className="text-sm font-black uppercase text-slate-600 mb-4">
Localização no Mapa
</h4>
<div className="relative">
<div
ref={mapRef}
className="w-full h-[500px] rounded-2xl border-2 border-slate-200 overflow-hidden"
/>
{!mapLoaded && (
<div className="absolute inset-0 flex items-center justify-center bg-slate-100">
<div className="text-center">
<div className="w-12 h-12 border-4 border-[#002147] border-t-transparent rounded-full animate-spin mx-auto mb-2"></div>
<p className="text-sm font-bold text-slate-600">Carregando mapa...</p>
</div>
</div>
)}
<div className="mt-3 p-3 bg-slate-50 rounded-xl">
<p className="text-xs text-slate-600">
<strong>Dica:</strong> Clique no mapa ou arraste o marcador para definir a localização exata do endereço.
</p>
</div>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="p-6 border-t border-slate-200 flex-shrink-0 flex items-center justify-end gap-3">
<button
onClick={onClose}
className="px-6 py-3 rounded-xl font-bold text-slate-700 hover:bg-slate-100 transition-colors"
>
Cancelar
</button>
<button
onClick={handleSave}
disabled={isLoading}
className="flex items-center gap-2 px-6 py-3 rounded-xl font-black bg-[#002147] text-white hover:bg-[#001a36] transition-all shadow-lg shadow-[#002147]/20 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
Salvando...
</>
) : (
<>
<Save className="w-4 h-4" />
Salvar Endereço
</>
)}
</button>
</div>
</div>
</div>
);
};
export default AddressFormModal;