1138 lines
39 KiB
TypeScript
1138 lines
39 KiB
TypeScript
import React, { useState, useEffect, useRef } from "react";
|
||
import {
|
||
MapContainer,
|
||
TileLayer,
|
||
Marker,
|
||
Popup,
|
||
useMapEvents,
|
||
useMap,
|
||
} from "react-leaflet";
|
||
import "leaflet/dist/leaflet.css";
|
||
import L from "leaflet";
|
||
import { Search, X, ChevronLeft } 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";
|
||
|
||
// 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;
|
||
}
|
||
|
||
// Component to change map view when city is selected
|
||
function CityChangeView({ center }: { center: [number, number] }) {
|
||
const map = useMap();
|
||
useEffect(() => {
|
||
map.setView(center, 13);
|
||
}, [center, map]);
|
||
return null;
|
||
}
|
||
|
||
// Component to handle map clicks
|
||
function MapClickHandler({
|
||
onMapClick,
|
||
}: {
|
||
onMapClick: (latlng: [number, number]) => void;
|
||
}) {
|
||
useMapEvents({
|
||
click: (e) => {
|
||
const { lat, lng } = e.latlng;
|
||
onMapClick([lat, lng]);
|
||
},
|
||
});
|
||
return null;
|
||
}
|
||
|
||
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[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,
|
||
});
|
||
|
||
// Fix Leaflet's default icon path issues when the component mounts
|
||
useEffect(() => {
|
||
// Fix Leaflet icon issues
|
||
delete (L.Icon.Default.prototype as { _getIconUrl?: string })._getIconUrl;
|
||
L.Icon.Default.mergeOptions({
|
||
iconUrl: "https://unpkg.com/leaflet@1.7.1/dist/images/marker-icon.png",
|
||
iconRetinaUrl:
|
||
"https://unpkg.com/leaflet@1.7.1/dist/images/marker-icon-2x.png",
|
||
shadowUrl:
|
||
"https://unpkg.com/leaflet@1.7.1/dist/images/marker-shadow.png",
|
||
});
|
||
|
||
// Cleanup function to remove map instance when component unmounts
|
||
return () => {
|
||
// This code ensures previous maps are cleaned up
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
const mapContainer = document.getElementById(mapIdRef.current);
|
||
if (mapContainer) {
|
||
// Clean up previous map on this element
|
||
// In Leaflet version 1+, using map._leaflet no longer works
|
||
// and instead removeAttribute should be used
|
||
mapContainer.innerHTML = "";
|
||
mapContainer.removeAttribute("data-leaflet-internal-id");
|
||
mapContainer.removeAttribute("_leaflet_id");
|
||
console.log("Map container cleaned up successfully");
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
// 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]);
|
||
|
||
// Function to get address from coordinates
|
||
const getAddressFromCoordinates = async (latlng: [number, number]) => {
|
||
try {
|
||
const response = await fetch(
|
||
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latlng[0]}&lon=${latlng[1]}&zoom=18&addressdetails=1&accept-language=fa&countrycodes=ir`,
|
||
{
|
||
headers: {
|
||
"Accept-Language": "fa",
|
||
},
|
||
}
|
||
);
|
||
const data = await response.json();
|
||
return data.display_name || "آدرس نامشخص";
|
||
} catch (error) {
|
||
console.error("Error fetching address:", error);
|
||
return "";
|
||
}
|
||
};
|
||
|
||
// Extract postal code from address data
|
||
const getPostalCodeFromAddress = async (latlng: [number, number]) => {
|
||
try {
|
||
const response = await fetch(
|
||
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latlng[0]}&lon=${latlng[1]}&zoom=18&addressdetails=1&accept-language=fa&countrycodes=ir`,
|
||
{
|
||
headers: {
|
||
"Accept-Language": "fa",
|
||
},
|
||
}
|
||
);
|
||
const data = await response.json();
|
||
|
||
if (data && data.address && data.address.postcode) {
|
||
return data.address.postcode;
|
||
}
|
||
return "";
|
||
} catch (error) {
|
||
console.error("Error fetching postal code:", error);
|
||
return "";
|
||
}
|
||
};
|
||
|
||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||
|
||
const handleMapClick = async (latlng: [number, number]) => {
|
||
const address = await getAddressFromCoordinates(latlng);
|
||
const postalCode = await getPostalCodeFromAddress(latlng);
|
||
|
||
setSelectedPosition(latlng);
|
||
setSelectedAddress(address);
|
||
|
||
// Don't update postal_code in edit mode, preserve the original value
|
||
setFormData({
|
||
...formData,
|
||
main_address: address,
|
||
// Don't set postal_code here in either mode, preserve the existing value in edit mode
|
||
latitude: latlng[0],
|
||
longitude: latlng[1],
|
||
});
|
||
|
||
// If using as a location picker in another component, call the callback
|
||
if (onLocationSelected) {
|
||
onLocationSelected({
|
||
position: latlng,
|
||
address,
|
||
postalCode: isEditing ? formData.postal_code : postalCode,
|
||
});
|
||
}
|
||
};
|
||
|
||
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;
|
||
handleMapClick([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 */}
|
||
<div className="dd z-0 h-[500px] w-full relative">
|
||
<div id={mapIdRef.current} className="h-full w-full">
|
||
{mounted && (
|
||
<MapContainer
|
||
key={mapIdRef.current}
|
||
center={currentCenter}
|
||
zoom={13}
|
||
scrollWheelZoom={true}
|
||
style={{ height: "100%", width: "100%" }}
|
||
>
|
||
<CityChangeView center={currentCenter} />
|
||
<TileLayer
|
||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||
/>
|
||
|
||
<MapClickHandler onMapClick={handleMapClick} />
|
||
{/* <LocationButton /> */}
|
||
|
||
{selectedPosition && (
|
||
<Marker position={selectedPosition}>
|
||
<Popup>
|
||
<div dir="rtl" className="text-right">
|
||
<p className="font-bold mb-1">آدرس:</p>
|
||
<p className="mb-2">{selectedAddress}</p>
|
||
<p className="mb-1">
|
||
<span className="font-bold">مختصات: </span>
|
||
{selectedPosition[0].toFixed(6)},{" "}
|
||
{selectedPosition[1].toFixed(6)}
|
||
</p>
|
||
</div>
|
||
</Popup>
|
||
</Marker>
|
||
)}
|
||
</MapContainer>
|
||
)}
|
||
</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>
|
||
);
|
||
}
|