import React, { useState, useEffect, useRef, useCallback } from "react"; import "maplibre-gl/dist/maplibre-gl.css"; import type { Map as MaplibreMap } from "maplibre-gl"; import { Search, X, ChevronLeft, MapPin } from "lucide-react"; import AppInput from "./AppInput"; import AppTextArea from "./AppTextArea"; import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, } from "../components/ui/drawer"; import { useServiceCreateAddress, useServiceUpdateAddress, } from "../utils/RequestHandler"; import { useToast } from "../hooks/use-toast"; import { useNavigate, useSearchParams } from "@remix-run/react"; import { Address } from "../../src/api/types"; import { iranianCities, iranianProvinces, getCitiesByProvince, type IranianCity, } from "../data/iranian-cities"; // Map.ir config (vector tiles + reverse geocoding). The key is a client-side // map key (sent with every tile request), provided via env. const MAPIR_KEY = import.meta.env.VITE_MAPIR_KEY || ""; const MAPIR_STYLE = "https://map.ir/vector/styles/main/mapir-style.json"; // Reverse geocode (lat/lng -> Persian address) via Map.ir. async function reverseGeocode(lat: number, lon: number): Promise { try { const res = await fetch(`https://map.ir/reverse?lat=${lat}&lon=${lon}`, { headers: { "x-api-key": MAPIR_KEY }, }); const data = await res.json(); return data?.address || data?.address_compact || "آدرس نامشخص"; } catch (error) { console.error("Error fetching address:", error); return ""; } } // Form data interface interface FormData { title: string; receiver_name: string; phone_number: string; main_address: string; postal_code: string; entry_reference: string; details: string; latitude: number; longitude: number; is_default: boolean; } // Props interface interface InteractiveMapProps { onLocationSelected?: (location: { position: [number, number]; address: string; postalCode?: string; }) => void; initialAddress?: Address; isEditing?: boolean; addressId?: string; formOnly?: boolean; // Show only the form without map } // Convert Persian/Arabic digits to English function convertPersianToEnglish(str: string): string { const persianDigits = "۰۱۲۳۴۵۶۷۸۹"; const arabicDigits = "٠١٢٣٤٥٦٧٨٩"; let result = str; for (let i = 0; i < 10; i++) { result = result.replace(new RegExp(persianDigits[i], "g"), i.toString()); result = result.replace(new RegExp(arabicDigits[i], "g"), i.toString()); } return result; } export default function InteractiveMap({ onLocationSelected, initialAddress, isEditing = false, addressId, formOnly = false, }: InteractiveMapProps) { const [selectedPosition, setSelectedPosition] = useState< [number, number] | null >(null); const [selectedAddress, setSelectedAddress] = useState(""); const [isDrawerOpen, setIsDrawerOpen] = useState(false); const [isCityDrawerOpen, setIsCityDrawerOpen] = useState(false); const [selectedCity, setSelectedCity] = useState( isEditing ? "" : "تهران" ); const [currentCenter, setCurrentCenter] = useState<[number, number]>( (iranianCities.find((c) => c.name === "تهران") || iranianCities[0]).position ); const [mounted, setMounted] = useState(false); const [formData, setFormData] = useState({ title: "", receiver_name: "", phone_number: "", main_address: "", postal_code: "", entry_reference: "", details: "", latitude: 0, longitude: 0, is_default: false, }); // State for city search const [citySearchTerm, setCitySearchTerm] = useState(""); const [filteredCities, setFilteredCities] = useState(iranianCities); const [selectedProvince, setSelectedProvince] = useState(null); // Use a ref for the unique map ID to prevent it changing on re-renders const mapIdRef = useRef( `map-${Math.random().toString(36).substring(2, 9)}-${Date.now()}-${isEditing ? "edit" : "add"}-${addressId || "new"}` ); const navigate = useNavigate(); const [searchParams] = useSearchParams(); // Set up the toast notification system const { toast } = useToast(); // Get return parameters from URL const returnTo = searchParams.get("returnTo"); const cartId = searchParams.get("cartId"); // Set up the mutation for creating addresses const createAddressMutation = useServiceCreateAddress(); // For address updates, we'll use the hook with current form data const updateAddressMutation = useServiceUpdateAddress(addressId || "", { title: formData.title, receiverName: formData.receiver_name, phoneNumber: formData.phone_number, mainAddress: formData.main_address, postalCode: formData.postal_code, entryReference: formData.entry_reference, details: formData.details, latitude: formData.latitude, longitude: formData.longitude, isDefault: formData.is_default, }); // MapLibre refs const mapContainerRef = useRef(null); const mapRef = useRef(null); const moveDebounceRef = useRef | null>(null); // Initialize with the existing address data when in editing mode useEffect(() => { if (isEditing && initialAddress) { // Set form data with initial values from address setFormData({ title: initialAddress.title || "", receiver_name: initialAddress.receiverName || "", phone_number: initialAddress.phoneNumber || "", main_address: initialAddress.mainAddress || "", postal_code: initialAddress.postalCode || "", entry_reference: initialAddress.entryReference || "", details: initialAddress.details || "", latitude: initialAddress.latitude || 0, longitude: initialAddress.longitude || 0, is_default: initialAddress.isDefault || false, }); if (initialAddress.latitude && initialAddress.longitude) { console.log( "Setting position from initial address:", initialAddress.latitude, initialAddress.longitude ); // Set the marker position to the address location setSelectedPosition([ initialAddress.latitude, initialAddress.longitude, ]); setSelectedAddress(initialAddress.mainAddress || ""); } } }, [isEditing, initialAddress]); // Handle city search useEffect(() => { let cities = selectedProvince ? getCitiesByProvince(selectedProvince) : iranianCities; if (citySearchTerm) { cities = cities.filter( (city) => city.name.includes(citySearchTerm) || city.province.includes(citySearchTerm) ); } setFilteredCities(cities); }, [citySearchTerm, selectedProvince]); const [isSubmitting, setIsSubmitting] = useState(false); const [errors, setErrors] = useState>({}); // Reverse-geocode a point and push it into the form. Used by the map's // center pin (on move) — replaces the old click handler. const applyLocation = useCallback( async (latlng: [number, number]) => { const address = await reverseGeocode(latlng[0], latlng[1]); setSelectedPosition(latlng); setSelectedAddress(address); setFormData((prev) => ({ ...prev, main_address: address, latitude: latlng[0], longitude: latlng[1], })); if (onLocationSelected) { onLocationSelected({ position: latlng, address }); } }, [onLocationSelected] ); // Keep a stable ref so the map's event listeners always call the latest. const applyLocationRef = useRef(applyLocation); useEffect(() => { applyLocationRef.current = applyLocation; }, [applyLocation]); // Initialize the MapLibre map (client-only, once) with the Map.ir style. useEffect(() => { if (!mounted || formOnly || !mapContainerRef.current || mapRef.current) { return; } let cancelled = false; (async () => { const maplibregl = (await import("maplibre-gl")).default; if (cancelled || !mapContainerRef.current) return; const start = selectedPosition || currentCenter; // [lat, lng] const map = new maplibregl.Map({ container: mapContainerRef.current, style: MAPIR_STYLE, center: [start[1], start[0]], // MapLibre uses [lng, lat] zoom: 14, attributionControl: false, transformRequest: (url: string) => url.startsWith("https://map.ir") ? { url, headers: { "x-api-key": MAPIR_KEY } } : { url }, }); mapRef.current = map; map.addControl( new maplibregl.NavigationControl({ showCompass: false }), "top-left" ); // Reverse-geocode the centre whenever the map settles. map.on("moveend", () => { if (moveDebounceRef.current) clearTimeout(moveDebounceRef.current); moveDebounceRef.current = setTimeout(() => { const c = map.getCenter(); applyLocationRef.current([c.lat, c.lng]); }, 350); }); map.on("load", () => { const c = map.getCenter(); applyLocationRef.current([c.lat, c.lng]); }); })(); return () => { cancelled = true; if (moveDebounceRef.current) clearTimeout(moveDebounceRef.current); if (mapRef.current) { mapRef.current.remove(); mapRef.current = null; } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [mounted, formOnly]); // Recenter the map when the chosen city changes. useEffect(() => { if (mapRef.current) { mapRef.current.flyTo({ center: [currentCenter[1], currentCenter[0]], zoom: 14, }); } }, [currentCenter]); const handleCitySelect = (cityName: string) => { setSelectedCity(cityName); const city = iranianCities.find((c) => c.name === cityName); if (city) { setCurrentCenter(city.position); } setSelectedProvince(null); setCitySearchTerm(""); setIsCityDrawerOpen(false); }; const handleOpenDrawer = () => { setIsDrawerOpen(true); }; const handleCloseDrawer = () => { setIsDrawerOpen(false); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setIsSubmitting(true); setErrors({}); // Validate required fields const newErrors: Record = {}; // عنوان آدرس if (!formData.title || !formData.title.trim()) { newErrors.title = "عنوان آدرس الزامی است"; } // نام گیرنده if (!formData.receiver_name || !formData.receiver_name.trim()) { newErrors.receiver_name = "نام گیرنده الزامی است"; } else if (formData.receiver_name.trim().length < 3) { newErrors.receiver_name = "نام گیرنده باید حداقل 3 کاراکتر باشد"; } // شماره تماس if (!formData.phone_number || !formData.phone_number.trim()) { newErrors.phone_number = "شماره تماس الزامی است"; } else { const phoneRegex = /^(\+98|0)?9\d{9}$/; const cleanPhone = convertPersianToEnglish( formData.phone_number.replace(/[\s-]/g, "") ); if (!phoneRegex.test(cleanPhone)) { newErrors.phone_number = "شماره تماس معتبر نیست (مثال: 09123456789)"; } } // آدرس کامل if (!formData.main_address || !formData.main_address.trim()) { newErrors.main_address = "آدرس الزامی است"; } else if (formData.main_address.trim().length < 10) { newErrors.main_address = "آدرس باید حداقل 10 کاراکتر باشد"; } // کد پستی if (!formData.postal_code || !formData.postal_code.trim()) { newErrors.postal_code = "کد پستی الزامی است"; } else { const postalCodeRegex = /^\d{10}$/; const cleanPostalCode = convertPersianToEnglish( formData.postal_code.replace(/[\s-]/g, "") ); if (!postalCodeRegex.test(cleanPostalCode)) { newErrors.postal_code = "کد پستی باید 10 رقم باشد"; } } // پلاک if (!formData.entry_reference || !formData.entry_reference.trim()) { newErrors.entry_reference = "پلاک الزامی است"; } if (Object.keys(newErrors).length > 0) { setErrors(newErrors); setIsSubmitting(false); return; } try { if (isEditing && addressId) { // Update existing address - use mutation instead of refetch console.log("Updating address with ID:", addressId); console.log("Form data for update:", formData); // In edit mode, use mutation for update await updateAddressMutation.mutateAsync(); toast({ title: "آدرس با موفقیت ویرایش شد", description: "آدرس شما با موفقیت ویرایش شد.", }); } else { // Create new address await createAddressMutation.mutateAsync(formData); toast({ title: "آدرس جدید با موفقیت اضافه شد", description: "آدرس جدید شما با موفقیت اضافه شد.", }); } // Redirect based on where the user came from if (returnTo === "cart" && cartId) { // Return to cart with step=1 to show shipping info navigate(`/cart/${cartId}?step=1`); } else { // Default behavior - go to address list navigate("/profile/location"); } } catch (error) { console.error("Error submitting address:", error); toast({ variant: "destructive", title: "خطا در ثبت آدرس", description: "متاسفانه خطایی در ثبت آدرس رخ داد. لطفا مجددا تلاش کنید.", }); } finally { setIsSubmitting(false); } }; // Clear validation errors when form data changes useEffect(() => { setErrors({}); }, [formData]); // ایجاد useEffect جدید برای ثبت ID منحصر به فرد نقشه در کنسول برای دیباگ useEffect(() => { console.log("Map ID created:", mapIdRef.current); }, []); // تنظیم حالت mounted در اولین رندر useEffect(() => { setMounted(true); return () => { // پاکسازی هنگام خروج از صفحه setMounted(false); console.log("Map component unmounting, cleaning up state"); }; }, []); // Initial center position setup - ensure it's synced with location in edit mode useEffect(() => { if ( isEditing && initialAddress && initialAddress.latitude && initialAddress.longitude ) { // Make sure the map center is always set to the location being edited initially // This is separate from the form data initialization to ensure map always stays centered // on the current location regardless of other data changes setCurrentCenter([initialAddress.latitude, initialAddress.longitude]); console.log("Map center synchronized with edited location coordinates"); } }, [isEditing, initialAddress]); // Add keyframes for the pulse animation useEffect(() => { const styleTag = document.createElement("style"); styleTag.innerHTML = ` @keyframes pulse { 0% { transform: translate(-50%, -50%) scale(0.5); opacity: 1; } 100% { transform: translate(-50%, -50%) scale(1.5); opacity: 0; } } `; document.head.appendChild(styleTag); // Listen for the custom location event const handleLocationFound = (event: Event) => { const customEvent = event as CustomEvent<{ lat: number; lng: number }>; const { lat, lng } = customEvent.detail; if (mapRef.current) { mapRef.current.flyTo({ center: [lng, lat], zoom: 15 }); } else { applyLocationRef.current([lat, lng]); } }; window.addEventListener( "user-location-found", handleLocationFound as EventListener ); return () => { document.head.removeChild(styleTag); window.removeEventListener( "user-location-found", handleLocationFound as EventListener ); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // If formOnly mode, render only the form if (formOnly) { return (
) => setFormData({ ...formData, title: e.target.value, }) } inputDir="rtl" /> {errors.title && (

{errors.title}

)}
) => setFormData({ ...formData, receiver_name: e.target.value, }) } inputDir="rtl" /> {errors.receiver_name && (

{errors.receiver_name}

)}
) => setFormData({ ...formData, phone_number: e.target.value, }) } inputDir={formData.phone_number ? "ltr" : "rtl"} /> {errors.phone_number && (

{errors.phone_number}

)}
) => setFormData({ ...formData, main_address: e.target.value, }) } inputDir="rtl" /> {errors.main_address && (

{errors.main_address}

)}
) => setFormData({ ...formData, postal_code: e.target.value, }) } inputDir="ltr" /> {errors.postal_code && (

{errors.postal_code}

)}
) => setFormData({ ...formData, entry_reference: e.target.value, }) } inputDir="rtl" /> {errors.entry_reference && (

{errors.entry_reference}

)}
) => setFormData({ ...formData, details: e.target.value, }) } inputDir="rtl" />
تنظیم به عنوان آدرس پیش‌فرض
); } return (
{/* City selector in a nice blue box */}
setIsCityDrawerOpen(true)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { setIsCityDrawerOpen(true); } }} >
{selectedCity}
{/* City Selector Drawer */} { setIsCityDrawerOpen(open); if (!open) { setSelectedProvince(null); setCitySearchTerm(""); } }}>
{selectedProvince ? ( ) : ( setIsCityDrawerOpen(false)} /> )} {selectedProvince ? `شهرهای ${selectedProvince}` : "انتخاب استان و شهر"}
) => setCitySearchTerm(e.target.value) } />
{/* Province/City List */}
{!selectedProvince && !citySearchTerm ? ( // Show provinces iranianProvinces.map((province) => ( )) ) : filteredCities.length > 0 ? ( // Show cities filteredCities.map((city) => ( )) ) : (

نتیجه‌ای یافت نشد

)}
{/* Map Container — MapLibre + Map.ir vector tiles. dir=ltr keeps the map's internal positioning correct inside the RTL page. */}
{/* Fixed center pin — the map moves under it; its tip marks the point */}
{/* Selected-address preview */} {selectedAddress && (

موقعیت انتخاب‌شده

{selectedAddress}

)}
{/* Select Location Button */} {!onLocationSelected && selectedPosition && !isDrawerOpen && ( )} {/* Address Form Drawer */} {!onLocationSelected && (
setIsDrawerOpen(false)} /> {isEditing ? "ویرایش آدرس" : "ثبت آدرس"}
) => setFormData({ ...formData, title: e.target.value, }) } inputDir="rtl" /> {errors.title && (

{errors.title}

)}
) => setFormData({ ...formData, receiver_name: e.target.value, }) } inputDir="rtl" /> {errors.receiver_name && (

{errors.receiver_name}

)}
) => setFormData({ ...formData, phone_number: e.target.value, }) } inputDir={formData.phone_number ? "ltr" : "rtl"} /> {errors.phone_number && (

{errors.phone_number}

)}
) => setFormData({ ...formData, main_address: e.target.value, }) } inputDir="rtl" /> {errors.main_address && (

{errors.main_address}

)}
) => setFormData({ ...formData, postal_code: e.target.value, }) } inputDir="ltr" /> {errors.postal_code && (

{errors.postal_code}

)}
) => setFormData({ ...formData, entry_reference: e.target.value, }) } inputDir="rtl" /> {errors.entry_reference && (

{errors.entry_reference}

)}
) => setFormData({ ...formData, details: e.target.value, }) } inputDir="rtl" />
تنظیم به عنوان آدرس پیش‌فرض
)} {/* Overlay background when drawer is open */} {!onLocationSelected && isDrawerOpen && (
{ if (e.key === "Escape") { handleCloseDrawer(); } }} >
)} {/* Selection button for location picker mode */} {onLocationSelected && selectedPosition && ( )}
); }