Replace the broken-looking Leaflet/OSM address picker with a MapLibre GL map using Map.ir vector tiles, plus Map.ir reverse geocoding for fast coordinates -> Persian address. - InteractiveMap: drop react-leaflet/leaflet; init a MapLibre map (Map.ir style via transformRequest x-api-key), a NavigationControl, and a fixed center pin — the map moves under the pin and the centre is reverse- geocoded on moveend (debounced). Wrapped dir=ltr so the map renders correctly inside the RTL page (fixes the scattered-tiles bug). Selected address shown in an overlay chip; city select / geolocation fly the map. - reverse geocoding: Nominatim -> Map.ir (https://map.ir/reverse), which returns proper Persian addresses (Nominatim was weak for Iran and its TOS forbids production use). - default map centre fixed to Tehran (matched the "تهران" selector). - key via VITE_MAPIR_KEY (documented in .env.example; real key in env). - deps: add maplibre-gl, remove leaflet/react-leaflet/@types/leaflet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1109 lines
39 KiB
TypeScript
1109 lines
39 KiB
TypeScript
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<string> {
|
||
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<string>("");
|
||
const [isDrawerOpen, setIsDrawerOpen] = useState<boolean>(false);
|
||
const [isCityDrawerOpen, setIsCityDrawerOpen] = useState<boolean>(false);
|
||
const [selectedCity, setSelectedCity] = useState<string>(
|
||
isEditing ? "" : "تهران"
|
||
);
|
||
const [currentCenter, setCurrentCenter] = useState<[number, number]>(
|
||
(iranianCities.find((c) => c.name === "تهران") || iranianCities[0]).position
|
||
);
|
||
const [mounted, setMounted] = useState<boolean>(false);
|
||
|
||
const [formData, setFormData] = useState<FormData>({
|
||
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<string>("");
|
||
const [filteredCities, setFilteredCities] =
|
||
useState<IranianCity[]>(iranianCities);
|
||
const [selectedProvince, setSelectedProvince] = useState<string | null>(null);
|
||
|
||
// Use a ref for the unique map ID to prevent it changing on re-renders
|
||
const mapIdRef = useRef<string>(
|
||
`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<HTMLDivElement>(null);
|
||
const mapRef = useRef<MaplibreMap | null>(null);
|
||
const moveDebounceRef = useRef<ReturnType<typeof setTimeout> | 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<boolean>(false);
|
||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||
|
||
// 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<string, string> = {};
|
||
|
||
// عنوان آدرس
|
||
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 (
|
||
<div className="flex z-10 flex-col w-full relative">
|
||
<div className="p-4 bg-WHITE">
|
||
<form onSubmit={handleSubmit} dir="rtl">
|
||
<div className="mb-4">
|
||
<AppInput
|
||
inputTitle="عنوان آدرس *"
|
||
inputValue={formData.title}
|
||
inputPlaceholder="مثال: خانه، محل کار، و..."
|
||
inputOnChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||
setFormData({
|
||
...formData,
|
||
title: e.target.value,
|
||
})
|
||
}
|
||
inputDir="rtl"
|
||
/>
|
||
{errors.title && (
|
||
<p className="text-xs text-red-500 mt-1">{errors.title}</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mb-4">
|
||
<AppInput
|
||
inputTitle="نام گیرنده *"
|
||
inputValue={formData.receiver_name}
|
||
inputPlaceholder="نام و نام خانوادگی گیرنده"
|
||
inputOnChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||
setFormData({
|
||
...formData,
|
||
receiver_name: e.target.value,
|
||
})
|
||
}
|
||
inputDir="rtl"
|
||
/>
|
||
{errors.receiver_name && (
|
||
<p className="text-xs text-red-500 mt-1">
|
||
{errors.receiver_name}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mb-4">
|
||
<AppInput
|
||
inputTitle="شماره تماس *"
|
||
inputValue={formData.phone_number}
|
||
inputPlaceholder="شماره تماس گیرنده"
|
||
inputOnChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||
setFormData({
|
||
...formData,
|
||
phone_number: e.target.value,
|
||
})
|
||
}
|
||
inputDir={formData.phone_number ? "ltr" : "rtl"}
|
||
/>
|
||
{errors.phone_number && (
|
||
<p className="text-xs text-red-500 mt-1">
|
||
{errors.phone_number}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mb-4">
|
||
<AppTextArea
|
||
inputTitle="آدرس کامل *"
|
||
inputValue={formData.main_address}
|
||
inputPlaceholder="آدرس دقیق"
|
||
inputOnChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||
setFormData({
|
||
...formData,
|
||
main_address: e.target.value,
|
||
})
|
||
}
|
||
inputDir="rtl"
|
||
/>
|
||
{errors.main_address && (
|
||
<p className="text-xs text-red-500 mt-1">
|
||
{errors.main_address}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mb-4">
|
||
<AppInput
|
||
inputTitle="کد پستی *"
|
||
inputValue={formData.postal_code}
|
||
inputPlaceholder="کد پستی ۱۰ رقمی"
|
||
inputOnChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||
setFormData({
|
||
...formData,
|
||
postal_code: e.target.value,
|
||
})
|
||
}
|
||
inputDir="ltr"
|
||
/>
|
||
{errors.postal_code && (
|
||
<p className="text-xs text-red-500 mt-1">
|
||
{errors.postal_code}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mb-4">
|
||
<AppInput
|
||
inputTitle="پلاک *"
|
||
inputValue={formData.entry_reference}
|
||
inputPlaceholder="پلاک"
|
||
inputOnChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||
setFormData({
|
||
...formData,
|
||
entry_reference: e.target.value,
|
||
})
|
||
}
|
||
inputDir="rtl"
|
||
/>
|
||
{errors.entry_reference && (
|
||
<p className="text-xs text-red-500 mt-1">
|
||
{errors.entry_reference}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mb-4">
|
||
<AppTextArea
|
||
inputTitle="توضیحات اضافی"
|
||
inputValue={formData.details}
|
||
inputPlaceholder="توضیحات اضافی"
|
||
inputOnChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||
setFormData({
|
||
...formData,
|
||
details: e.target.value,
|
||
})
|
||
}
|
||
inputDir="rtl"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between p-3 bg-GRAY3 rounded-lg mb-4">
|
||
<span className="text-sm font-medium text-BLACK2">
|
||
تنظیم به عنوان آدرس پیشفرض
|
||
</span>
|
||
<label className="relative inline-flex items-center cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={formData.is_default}
|
||
onChange={(e) =>
|
||
setFormData({
|
||
...formData,
|
||
is_default: e.target.checked,
|
||
})
|
||
}
|
||
className="sr-only peer"
|
||
aria-label="تنظیم به عنوان آدرس پیشفرض"
|
||
/>
|
||
<div className="w-11 h-6 bg-WHITE2 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-WHITE after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-WHITE after:border-GRAY3 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-VITROWN_BLUE border"></div>
|
||
</label>
|
||
</div>
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={isSubmitting}
|
||
className="w-full bg-BLACK text-WHITE h-[55px] py-3 rounded-md text-center font-medium"
|
||
>
|
||
{isSubmitting ? (
|
||
<>
|
||
<svg
|
||
className="animate-spin -ml-1 mr-3 h-5 w-5 text-WHITE inline"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<circle
|
||
className="opacity-25"
|
||
cx="12"
|
||
cy="12"
|
||
r="10"
|
||
stroke="currentColor"
|
||
strokeWidth="4"
|
||
></circle>
|
||
<path
|
||
className="opacity-75"
|
||
fill="currentColor"
|
||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||
></path>
|
||
</svg>
|
||
درحال ذخیره...
|
||
</>
|
||
) : (
|
||
"ثبت تغییرات"
|
||
)}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex z-10 flex-col w-full relative">
|
||
{/* City selector in a nice blue box */}
|
||
<div
|
||
role="button"
|
||
tabIndex={0}
|
||
className="bg-WHITE"
|
||
onClick={() => setIsCityDrawerOpen(true)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
setIsCityDrawerOpen(true);
|
||
}
|
||
}}
|
||
>
|
||
<div className="p-2 flex items-center gap-2 justify-center">
|
||
<span className="font-bold text-B16">{selectedCity}</span>
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
className="text-B16"
|
||
>
|
||
<polyline points="6 9 12 15 18 9"></polyline>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
|
||
{/* City Selector Drawer */}
|
||
<Drawer open={isCityDrawerOpen} onOpenChange={(open) => {
|
||
setIsCityDrawerOpen(open);
|
||
if (!open) {
|
||
setSelectedProvince(null);
|
||
setCitySearchTerm("");
|
||
}
|
||
}}>
|
||
<DrawerContent className="h-[90vh]">
|
||
<DrawerHeader className="flex flex-col py-3">
|
||
<div className="flex relative pt-2 text-B12 font-bold items-center justify-center w-full">
|
||
{selectedProvince ? (
|
||
<button
|
||
className="absolute right-4 flex items-center gap-1 text-primary"
|
||
onClick={() => setSelectedProvince(null)}
|
||
>
|
||
<ChevronLeft className="w-5 h-5" />
|
||
<span className="text-sm">استانها</span>
|
||
</button>
|
||
) : (
|
||
<X
|
||
className="absolute right-4 cursor-pointer"
|
||
onClick={() => setIsCityDrawerOpen(false)}
|
||
/>
|
||
)}
|
||
<DrawerTitle>
|
||
{selectedProvince ? `شهرهای ${selectedProvince}` : "انتخاب استان و شهر"}
|
||
</DrawerTitle>
|
||
</div>
|
||
</DrawerHeader>
|
||
|
||
<div className="px-4">
|
||
<AppInput
|
||
inputTitle={""}
|
||
inputDir="rtl"
|
||
inputIcon={Search}
|
||
inputValue={citySearchTerm}
|
||
inputPlaceholder={selectedProvince ? "جستجوی شهر..." : "جستجوی استان یا شهر..."}
|
||
inputOnChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||
setCitySearchTerm(e.target.value)
|
||
}
|
||
/>
|
||
</div>
|
||
|
||
{/* Province/City List */}
|
||
<div className="flex-1 overflow-auto">
|
||
<div className="grid grid-cols-1">
|
||
{!selectedProvince && !citySearchTerm ? (
|
||
// Show provinces
|
||
iranianProvinces.map((province) => (
|
||
<button
|
||
key={province}
|
||
onClick={() => setSelectedProvince(province)}
|
||
className="p-3 border-input focus-visible:outline-none flex items-center justify-between text-BLACK2 px-4 border-b w-full hover:bg-gray-50"
|
||
>
|
||
<span>{province}</span>
|
||
<ChevronLeft className="w-5 h-5 text-GRAY" />
|
||
</button>
|
||
))
|
||
) : filteredCities.length > 0 ? (
|
||
// Show cities
|
||
filteredCities.map((city) => (
|
||
<button
|
||
key={`${city.province}-${city.name}`}
|
||
onClick={() => handleCitySelect(city.name)}
|
||
className="p-3 border-input focus-visible:outline-none flex flex-col items-start text-BLACK2 px-4 border-b w-full hover:bg-gray-50"
|
||
>
|
||
<span className="font-medium">{city.name}</span>
|
||
{!selectedProvince && (
|
||
<span className="text-xs text-GRAY">{city.province}</span>
|
||
)}
|
||
</button>
|
||
))
|
||
) : (
|
||
<div className="text-center py-8 text-GRAY">
|
||
<p>نتیجهای یافت نشد</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</DrawerContent>
|
||
</Drawer>
|
||
|
||
{/* Map Container — MapLibre + Map.ir vector tiles. dir=ltr keeps the
|
||
map's internal positioning correct inside the RTL page. */}
|
||
<div className="dd z-0 h-[500px] w-full relative" dir="ltr">
|
||
<div
|
||
ref={mapContainerRef}
|
||
id={mapIdRef.current}
|
||
className="h-full w-full"
|
||
/>
|
||
|
||
{/* Fixed center pin — the map moves under it; its tip marks the point */}
|
||
<div className="pointer-events-none absolute left-1/2 top-1/2 z-[5] -translate-x-1/2 -translate-y-full drop-shadow-md">
|
||
<MapPin size={40} className="fill-RED text-RED" strokeWidth={1.5} />
|
||
</div>
|
||
|
||
{/* Selected-address preview */}
|
||
{selectedAddress && (
|
||
<div
|
||
dir="rtl"
|
||
className="absolute left-3 right-3 bottom-3 z-[5] bg-WHITE/95 backdrop-blur rounded-xl shadow-md px-3.5 py-2.5"
|
||
>
|
||
<p className="text-R10 text-GRAY mb-0.5">موقعیت انتخابشده</p>
|
||
<p className="text-R12 font-bold line-clamp-2">{selectedAddress}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Select Location Button */}
|
||
{!onLocationSelected && selectedPosition && !isDrawerOpen && (
|
||
<button
|
||
onClick={handleOpenDrawer}
|
||
className="max-w-md mx-auto fixed bottom-20 left-4 right-4 bg-BLACK text-WHITE py-3 rounded-md text-center font-medium z-30"
|
||
>
|
||
{isEditing ? "ثبت موقعیت جدید و ادامه ویرایش" : "انتخاب این مکان"}
|
||
</button>
|
||
)}
|
||
|
||
{/* Address Form Drawer */}
|
||
{!onLocationSelected && (
|
||
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
|
||
<DrawerContent className="h-[90vh]">
|
||
<DrawerHeader className="flex flex-col py-3">
|
||
<div className="flex relative pt-2 text-B12 font-bold items-center justify-center w-full">
|
||
<X
|
||
className="absolute right-4"
|
||
onClick={() => setIsDrawerOpen(false)}
|
||
/>
|
||
<DrawerTitle>
|
||
{isEditing ? "ویرایش آدرس" : "ثبت آدرس"}
|
||
</DrawerTitle>
|
||
</div>
|
||
</DrawerHeader>
|
||
|
||
<div className="p-4 overflow-y-auto flex-1 max-h-[calc(90vh-5rem)]">
|
||
<form onSubmit={handleSubmit} dir="rtl">
|
||
<div className="mb-4">
|
||
<AppInput
|
||
inputTitle="عنوان آدرس *"
|
||
inputValue={formData.title}
|
||
inputPlaceholder="مثال: خانه، محل کار، و..."
|
||
inputOnChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||
setFormData({
|
||
...formData,
|
||
title: e.target.value,
|
||
})
|
||
}
|
||
inputDir="rtl"
|
||
/>
|
||
{errors.title && (
|
||
<p className="text-xs text-red-500 mt-1">{errors.title}</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mb-4">
|
||
<AppInput
|
||
inputTitle="نام گیرنده *"
|
||
inputValue={formData.receiver_name}
|
||
inputPlaceholder="نام و نام خانوادگی گیرنده"
|
||
inputOnChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||
setFormData({
|
||
...formData,
|
||
receiver_name: e.target.value,
|
||
})
|
||
}
|
||
inputDir="rtl"
|
||
/>
|
||
{errors.receiver_name && (
|
||
<p className="text-xs text-red-500 mt-1">
|
||
{errors.receiver_name}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mb-4">
|
||
<AppInput
|
||
inputTitle="شماره تماس *"
|
||
inputValue={formData.phone_number}
|
||
inputPlaceholder="شماره تماس گیرنده"
|
||
inputOnChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||
setFormData({
|
||
...formData,
|
||
phone_number: e.target.value,
|
||
})
|
||
}
|
||
inputDir={formData.phone_number ? "ltr" : "rtl"}
|
||
/>
|
||
{errors.phone_number && (
|
||
<p className="text-xs text-red-500 mt-1">
|
||
{errors.phone_number}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mb-4">
|
||
<AppTextArea
|
||
inputTitle="آدرس کامل *"
|
||
inputValue={formData.main_address}
|
||
inputPlaceholder="آدرس دقیق"
|
||
inputOnChange={(
|
||
e: React.ChangeEvent<HTMLTextAreaElement>
|
||
) =>
|
||
setFormData({
|
||
...formData,
|
||
main_address: e.target.value,
|
||
})
|
||
}
|
||
inputDir="rtl"
|
||
/>
|
||
{errors.main_address && (
|
||
<p className="text-xs text-red-500 mt-1">
|
||
{errors.main_address}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mb-4">
|
||
<AppInput
|
||
inputTitle="کد پستی *"
|
||
inputValue={formData.postal_code}
|
||
inputPlaceholder="کد پستی ۱۰ رقمی"
|
||
inputOnChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||
setFormData({
|
||
...formData,
|
||
postal_code: e.target.value,
|
||
})
|
||
}
|
||
inputDir="ltr"
|
||
/>
|
||
{errors.postal_code && (
|
||
<p className="text-xs text-red-500 mt-1">
|
||
{errors.postal_code}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mb-4">
|
||
<AppInput
|
||
inputTitle="پلاک *"
|
||
inputValue={formData.entry_reference}
|
||
inputPlaceholder="پلاک"
|
||
inputOnChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||
setFormData({
|
||
...formData,
|
||
entry_reference: e.target.value,
|
||
})
|
||
}
|
||
inputDir="rtl"
|
||
/>
|
||
{errors.entry_reference && (
|
||
<p className="text-xs text-red-500 mt-1">
|
||
{errors.entry_reference}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mb-4">
|
||
<AppTextArea
|
||
inputTitle="توضیحات اضافی"
|
||
inputValue={formData.details}
|
||
inputPlaceholder="توضیحات اضافی"
|
||
inputOnChange={(
|
||
e: React.ChangeEvent<HTMLTextAreaElement>
|
||
) =>
|
||
setFormData({
|
||
...formData,
|
||
details: e.target.value,
|
||
})
|
||
}
|
||
inputDir="rtl"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between p-3 bg-GRAY3 rounded-lg">
|
||
<span className="text-sm font-medium text-BLACK2">
|
||
تنظیم به عنوان آدرس پیشفرض
|
||
</span>
|
||
<label className="relative inline-flex items-center cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={formData.is_default}
|
||
onChange={(e) =>
|
||
setFormData({
|
||
...formData,
|
||
is_default: e.target.checked,
|
||
})
|
||
}
|
||
className="sr-only peer"
|
||
aria-label="تنظیم به عنوان آدرس پیشفرض"
|
||
/>
|
||
<div className="w-11 h-6 bg-WHITE2 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-WHITE after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-WHITE after:border-GRAY3 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-VITROWN_BLUE border"></div>
|
||
</label>
|
||
</div>
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={isSubmitting}
|
||
className="w-full bg-BLACK text-WHITE h-[55px] py-3 rounded-md text-center font-medium mt-3"
|
||
>
|
||
{isSubmitting ? (
|
||
<>
|
||
<svg
|
||
className="animate-spin -ml-1 mr-3 h-5 w-5 text-WHITE inline"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<circle
|
||
className="opacity-25"
|
||
cx="12"
|
||
cy="12"
|
||
r="10"
|
||
stroke="currentColor"
|
||
strokeWidth="4"
|
||
></circle>
|
||
<path
|
||
className="opacity-75"
|
||
fill="currentColor"
|
||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||
></path>
|
||
</svg>
|
||
درحال ذخیره...
|
||
</>
|
||
) : isEditing ? (
|
||
"ثبت تغییرات"
|
||
) : (
|
||
"اعمال تغییرات"
|
||
)}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</DrawerContent>
|
||
</Drawer>
|
||
)}
|
||
|
||
{/* Overlay background when drawer is open */}
|
||
{!onLocationSelected && isDrawerOpen && (
|
||
<div
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-label="Close drawer"
|
||
className="fixed inset-0 bg-BLACK bg-opacity-30 z-40"
|
||
onClick={handleCloseDrawer}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Escape") {
|
||
handleCloseDrawer();
|
||
}
|
||
}}
|
||
></div>
|
||
)}
|
||
|
||
{/* Selection button for location picker mode */}
|
||
{onLocationSelected && selectedPosition && (
|
||
<button
|
||
onClick={() => {
|
||
if (onLocationSelected && selectedPosition) {
|
||
onLocationSelected({
|
||
position: selectedPosition,
|
||
address: selectedAddress,
|
||
postalCode: formData.postal_code,
|
||
});
|
||
}
|
||
}}
|
||
className="fixed bottom-4 left-4 right-4 bg-blue-600 text-WHITE py-3 rounded-md text-center font-medium z-30"
|
||
>
|
||
تایید و انتخاب این موقعیت
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|