classify web

This commit is contained in:
Husanjonazamov
2026-02-24 12:52:49 +05:00
commit 64af77101f
310 changed files with 45449 additions and 0 deletions

View File

@@ -0,0 +1,117 @@
import { useSelector } from "react-redux";
import {
getDefaultLatitude,
getDefaultLongitude,
} from "@/redux/reducer/settingSlice";
import { getCityData } from "@/redux/reducer/locationSlice";
import { useEffect, useRef } from "react";
import {
Circle,
MapContainer,
Marker,
TileLayer,
useMapEvents,
} from "react-leaflet";
import "leaflet/dist/leaflet.css";
import L from "leaflet";
// Fix Leaflet default marker icon issue
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl:
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png",
iconUrl:
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png",
shadowUrl:
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png",
});
const MapClickHandler = ({ onMapClick }) => {
useMapEvents({
click: (e) => {
onMapClick(e.latlng);
},
});
return null;
};
const GetLocationWithMap = ({ position, getLocationWithMap, KmRange }) => {
const latitude = useSelector(getDefaultLatitude);
const longitude = useSelector(getDefaultLongitude);
const globalPos = useSelector(getCityData);
const mapRef = useRef();
const placeHolderPos = {
lat: globalPos?.lat,
lng: globalPos?.long,
};
const markerLatLong =
position?.lat && position?.lng ? position : placeHolderPos;
useEffect(() => {
if (mapRef.current && markerLatLong.lat && markerLatLong.lng) {
mapRef.current.flyTo(
[markerLatLong.lat, markerLatLong.lng],
mapRef.current.getZoom()
);
}
}, [markerLatLong?.lat, markerLatLong?.lng]);
const containerStyle = {
width: "100%",
height: "300px",
borderRadius: "4px",
};
const handleMapClick = (latlng) => {
if (getLocationWithMap) {
getLocationWithMap({
lat: latlng.lat,
lng: latlng.lng,
});
}
};
return (
<MapContainer
style={containerStyle}
center={[markerLatLong?.lat || latitude, markerLatLong?.lng || longitude]}
zoom={6}
ref={mapRef}
whenCreated={(mapInstance) => {
mapRef.current = mapInstance;
}}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MapClickHandler onMapClick={handleMapClick} />
<Marker
position={[
markerLatLong?.lat || latitude,
markerLatLong?.lng || longitude,
]}
></Marker>
<Circle
center={[
markerLatLong?.lat || latitude,
markerLatLong?.lng || longitude,
]}
radius={KmRange * 1000} // radius in meters
pathOptions={{
color: getComputedStyle(document.documentElement)
.getPropertyValue("--primary-color")
.trim(),
fillColor: getComputedStyle(document.documentElement)
.getPropertyValue("--primary-color")
.trim(),
fillOpacity: 0.2,
}}
/>
</MapContainer>
);
};
export default GetLocationWithMap;

View File

@@ -0,0 +1,251 @@
import { saveCity } from "@/redux/reducer/locationSlice";
import { getIsPaidApi } from "@/redux/reducer/settingSlice";
import { t } from "@/utils";
import { getLocationApi } from "@/utils/api";
import { usePathname } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useSelector } from "react-redux";
import { useDebounce } from "use-debounce";
import { useNavigate } from "../Common/useNavigate";
import { MapPin } from "lucide-react";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
const LandingAdEditSearchAutocomplete = ({
saveOnSuggestionClick,
OnHide,
setSelectedLocation,
}) => {
const isSuggestionClick = useRef(false);
const IsPaidApi = useSelector(getIsPaidApi);
const { navigate } = useNavigate();
const pathname = usePathname();
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebounce(search, 500);
const [autoState, setAutoState] = useState({
suggestions: [],
loading: false,
show: false,
});
const sessionTokenRef = useRef(null);
// Generate a new session token (UUID v4)
const generateSessionToken = () => {
// Use crypto.randomUUID() if available (modern browsers)
// Fallback to a simple UUID generator
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID();
}
// Fallback UUID generator
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
/[xy]/g,
function (c) {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
}
);
};
// Fetch suggestions
useEffect(() => {
const fetchSuggestions = async () => {
if (isSuggestionClick.current) {
isSuggestionClick.current = false;
return;
}
if (debouncedSearch && debouncedSearch.length > 1) {
setAutoState((prev) => ({ ...prev, loading: true, show: true }));
try {
// Generate new session token for new search session
// Only generate if we don't have one or if search changed significantly
if (!sessionTokenRef.current) {
sessionTokenRef.current = generateSessionToken();
}
const response = await getLocationApi.getLocation({
search: debouncedSearch,
lang: "en",
// Only include sessiontoken for Google Places API (IsPaidApi)
...(IsPaidApi && { session_id: sessionTokenRef.current }),
});
if (IsPaidApi) {
const results = response?.data?.data?.predictions || [];
setAutoState({ suggestions: results, loading: false, show: true });
} else {
const results = response?.data?.data || [];
const formattedResults = results.map((result) => ({
description: [
result?.area_translation,
result?.city_translation,
result?.state_translation,
result?.country_translation,
]
.filter(Boolean)
.join(", "),
original: result,
}));
setAutoState({
suggestions: formattedResults,
loading: false,
show: true,
});
}
} catch (error) {
console.log("error", error);
setAutoState({ suggestions: [], loading: false, show: true });
}
} else {
// Reset session token when search is cleared
sessionTokenRef.current = null;
setAutoState({ suggestions: [], loading: false, show: false });
}
};
fetchSuggestions();
}, [debouncedSearch, IsPaidApi]);
const handleSuggestionClick = async (suggestion) => {
isSuggestionClick.current = true;
if (IsPaidApi) {
// Use the same session token from autocomplete request
// This groups autocomplete + place details into one billing session
const response = await getLocationApi.getLocation({
place_id: suggestion.place_id,
lang: "en",
// Use the same session token from autocomplete (only if it exists)
...(sessionTokenRef.current && {
session_id: sessionTokenRef.current,
}),
});
const result = response?.data?.data?.results?.[0];
const addressComponents = result.address_components || [];
const getAddressComponent = (type) => {
const component = addressComponents.find((comp) =>
comp.types.includes(type)
);
return component?.long_name || "";
};
const city = getAddressComponent("locality");
const state = getAddressComponent("administrative_area_level_1");
const country = getAddressComponent("country");
const data = {
lat: result?.geometry?.location?.lat,
long: result?.geometry?.location?.lng,
city,
state,
country,
formattedAddress: suggestion?.description,
};
setSearch(suggestion?.description || "");
setAutoState({ suggestions: [], loading: false, show: false });
// Reset session token after place details request (session complete)
sessionTokenRef.current = null;
if (saveOnSuggestionClick) {
saveCity(data);
OnHide?.();
// avoid redirect if already on home page otherwise router.push triggering server side api calls
if (pathname !== "/") {
navigate("/");
}
} else {
setSelectedLocation(data);
}
} else {
const original = suggestion.original;
const data = {
lat: original?.latitude,
long: original?.longitude,
city: original?.city || "",
state: original?.state || "",
country: original?.country || "",
formattedAddress: suggestion.description || "",
area: original?.area || "",
areaId: original?.area_id || "",
};
setSearch(suggestion?.description || "");
setAutoState({ suggestions: [], loading: false, show: false });
if (saveOnSuggestionClick) {
saveCity(data);
OnHide?.();
// avoid redirect if already on home page otherwise router.push triggering server side api calls
if (pathname !== "/") {
navigate("/");
}
} else {
setSelectedLocation(data);
}
}
};
return (
<>
<div className="relative w-full">
<Command
shouldFilter={false} // VERY IMPORTANT
>
<CommandInput
placeholder={t("selectLocation")}
value={search}
onValueChange={(value) => {
setSearch(value);
if (!sessionTokenRef.current) {
sessionTokenRef.current = generateSessionToken();
}
}}
onFocus={() => {
if (autoState.suggestions.length > 0) {
setAutoState((p) => ({ ...p, show: true }));
}
}}
className="h-0"
wrapperClassName="border-b-0"
/>
{autoState.show &&
(autoState.suggestions.length > 0 || autoState.loading) && (
<CommandList className="absolute top-full left-0 right-0 z-[1500] max-h-[220px] mt-4 overflow-y-auto rounded-lg border bg-white shadow-lg w-full">
{autoState.loading && (
<CommandEmpty>{t("loading")}</CommandEmpty>
)}
<CommandGroup>
{autoState.suggestions.map((s, idx) => (
<CommandItem
key={idx}
value={s.description}
onSelect={() => {
handleSuggestionClick(s);
setAutoState((p) => ({ ...p, show: false }));
}}
className="ltr:text-left rtl:text-right"
>
<MapPin size={16} className="flex-shrink-0" />
<span className="text-sm">
{s.description || "Unknown"}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
)}
</Command>
</div>
</>
);
};
export default LandingAdEditSearchAutocomplete;

View File

@@ -0,0 +1,43 @@
"use client";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { getCityData } from "@/redux/reducer/locationSlice";
import { useState } from "react";
import { useSelector } from "react-redux";
import LocationSelector from "./LocationSelector";
import MapLocation from "./MapLocation";
import { getIsPaidApi } from "@/redux/reducer/settingSlice";
const LocationModal = ({ IsLocationModalOpen, setIsLocationModalOpen }) => {
const IsPaidApi = useSelector(getIsPaidApi);
const [IsMapLocation, setIsMapLocation] = useState(IsPaidApi);
const cityData = useSelector(getCityData);
const [selectedCity, setSelectedCity] = useState(cityData || "");
return (
<Dialog open={IsLocationModalOpen} onOpenChange={setIsLocationModalOpen}>
<DialogContent
onInteractOutside={(e) => e.preventDefault()}
className="!gap-6"
>
{IsMapLocation ? (
<MapLocation
OnHide={() => setIsLocationModalOpen(false)}
selectedCity={selectedCity}
setSelectedCity={setSelectedCity}
setIsMapLocation={setIsMapLocation}
IsPaidApi={IsPaidApi}
/>
) : (
<LocationSelector
OnHide={() => setIsLocationModalOpen(false)}
setSelectedCity={setSelectedCity}
IsMapLocation={IsMapLocation}
setIsMapLocation={setIsMapLocation}
/>
)}
</DialogContent>
</Dialog>
);
};
export default LocationModal;

View File

@@ -0,0 +1,578 @@
import { useEffect, useRef, useState } from "react";
import { IoSearch } from "react-icons/io5";
import { t } from "@/utils";
import { MdArrowBack, MdOutlineKeyboardArrowRight } from "react-icons/md";
import { BiCurrentLocation } from "react-icons/bi";
import { DialogHeader, DialogTitle } from "../ui/dialog";
import { cn } from "@/lib/utils";
import { Skeleton } from "../ui/skeleton";
import NoData from "../EmptyStates/NoData";
import { Loader2, SearchIcon } from "lucide-react";
import { useInView } from "react-intersection-observer";
import {
getAreasApi,
getCitiesApi,
getCoutriesApi,
getStatesApi,
} from "@/utils/api";
import {
getIsBrowserSupported,
resetCityData,
saveCity,
setKilometerRange,
} from "@/redux/reducer/locationSlice";
import { useDispatch, useSelector } from "react-redux";
import { getMinRange } from "@/redux/reducer/settingSlice";
import { usePathname } from "next/navigation";
import { useDebounce } from "use-debounce";
import SearchAutocomplete from "./SearchAutocomplete";
import { CurrentLanguageData } from "@/redux/reducer/languageSlice";
import { useNavigate } from "../Common/useNavigate";
import useGetLocation from "../Layout/useGetLocation";
const LocationSelector = ({ OnHide, setSelectedCity, setIsMapLocation }) => {
const CurrentLanguage = useSelector(CurrentLanguageData);
const dispatch = useDispatch();
const { navigate } = useNavigate();
const pathname = usePathname();
const minLength = useSelector(getMinRange);
const { ref, inView } = useInView();
const IsBrowserSupported = useSelector(getIsBrowserSupported);
const viewHistory = useRef([]);
const skipNextSearchEffect = useRef(false);
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebounce(search, 500);
const [locationStatus, setLocationStatus] = useState(null);
const [currentView, setCurrentView] = useState("countries");
const [selectedLocation, setSelectedLocation] = useState({
country: null,
state: null,
city: null,
area: null,
});
const [locationData, setLocationData] = useState({
items: [],
currentPage: 1,
hasMore: false,
isLoading: false,
isLoadMore: true,
});
const { fetchLocationData } = useGetLocation();
useEffect(() => {
if (skipNextSearchEffect.current) {
skipNextSearchEffect.current = false;
return;
}
fetchData(debouncedSearch);
}, [debouncedSearch]);
useEffect(() => {
if (inView && locationData?.hasMore && !locationData?.isLoading) {
fetchData(debouncedSearch, locationData?.currentPage + 1);
}
}, [inView]);
const handleSubmitLocation = () => {
minLength > 0
? dispatch(setKilometerRange(minLength))
: dispatch(setKilometerRange(0));
// avoid redirect if already on home page otherwise router.push triggering server side api calls
if (pathname !== "/") {
navigate("/");
}
};
const fetchData = async (
search = "",
page = 1,
view = currentView,
location = selectedLocation
) => {
try {
setLocationData((prev) => ({
...prev,
isLoading: page === 1,
isLoadMore: page > 1,
}));
let response;
const params = { page };
if (search) {
params.search = search;
}
switch (view) {
case "countries":
response = await getCoutriesApi.getCoutries(params);
break;
case "states":
response = await getStatesApi.getStates({
...params,
country_id: location.country.id,
});
break;
case "cities":
response = await getCitiesApi.getCities({
...params,
state_id: location.state.id,
});
break;
case "areas":
response = await getAreasApi.getAreas({
...params,
city_id: location.city.id,
});
break;
}
if (response.data.error === false) {
const items = response.data.data.data;
// MOD: if no results and not on countries, auto-save & close
if (items.length === 0 && view !== "countries" && !search) {
switch (view) {
case "states":
saveCity({
city: "",
state: "",
country: location.country.name,
lat: location.country.latitude,
long: location.country.longitude,
formattedAddress: location.country.translated_name,
});
break;
case "cities":
saveCity({
city: "",
state: location.state.name,
country: location.country.name,
lat: location.state.latitude,
long: location.state.longitude,
formattedAddress: [
location?.state.translated_name,
location?.country.translated_name,
]
.filter(Boolean)
.join(", "),
});
break;
case "areas":
saveCity({
city: location.city.name,
state: location.state.name,
country: location.country.name,
lat: location.city.latitude,
long: location.city.longitude,
formattedAddress: [
location?.city.translated_name,
location?.state.translated_name,
location?.country.translated_name,
]
.filter(Boolean)
.join(", "),
});
break;
}
handleSubmitLocation();
OnHide();
return; // stop further processing
}
setLocationData((prev) => ({
...prev,
items:
page > 1
? [...prev.items, ...response.data.data.data]
: response.data.data.data,
hasMore:
response.data.data.current_page < response.data.data.last_page,
currentPage: response.data.data.current_page,
}));
}
} catch (error) {
console.log(error);
} finally {
setLocationData((prev) => ({
...prev,
isLoading: false,
isLoadMore: false,
}));
}
};
const handleItemSelect = async (item) => {
// MOD: push current state onto history
viewHistory.current.push({
view: currentView,
location: selectedLocation,
dataState: locationData,
search: search,
});
let nextView = "";
let newLocation = {};
switch (currentView) {
case "countries":
newLocation = {
...selectedLocation,
country: item,
state: null,
city: null,
area: null,
};
nextView = "states";
break;
case "states":
newLocation = {
...selectedLocation,
state: item,
city: null,
area: null,
};
nextView = "cities";
break;
case "cities":
newLocation = {
...selectedLocation,
city: item,
area: null,
};
nextView = "areas";
break;
case "areas":
saveCity({
country: selectedLocation?.country?.name,
state: selectedLocation?.state?.name,
city: selectedLocation?.city?.name,
area: item?.name,
areaId: item?.id,
lat: item?.latitude,
long: item?.longitude,
formattedAddress: [
item?.translated_name,
selectedLocation?.city?.translated_name,
selectedLocation?.state?.translated_name,
selectedLocation?.country?.translated_name,
]
.filter(Boolean)
.join(", "),
});
handleSubmitLocation();
OnHide();
return;
}
setSelectedLocation(newLocation);
setCurrentView(nextView);
await fetchData("", 1, nextView, newLocation);
if (search) {
skipNextSearchEffect.current = true;
setSearch("");
}
};
const getPlaceholderText = () => {
switch (currentView) {
case "countries":
return `${t("search")} ${t("country")}`;
case "states":
return `${t("search")} ${t("state")}`;
case "cities":
return `${t("search")} ${t("city")}`;
case "areas":
return `${t("search")} ${t("area")}`;
default:
return `${t("search")} ${t("location")}`;
}
};
const getFormattedLocation = () => {
if (!selectedLocation) return t("location");
const parts = [];
if (selectedLocation.area?.translated_name)
parts.push(selectedLocation.area.translated_name);
if (selectedLocation.city?.translated_name)
parts.push(selectedLocation.city.translated_name);
if (selectedLocation.state?.translated_name)
parts.push(selectedLocation.state.translated_name);
if (selectedLocation.country?.translated_name)
parts.push(selectedLocation.country.translated_name);
return parts.length > 0 ? parts.join(", ") : t("location");
};
const handleBack = async () => {
const prev = viewHistory.current.pop();
if (!prev) return;
setCurrentView(prev.view);
setSelectedLocation(prev.location);
if (search !== prev.search) {
skipNextSearchEffect.current = true;
setSearch(prev.search);
}
if (prev.dataState) {
setLocationData(prev.dataState);
} else {
await fetchData(prev.search ?? "", 1, prev.view, prev.location);
}
};
const getTitle = () => {
switch (currentView) {
case "countries":
return t("country");
case "states":
return t("state");
case "cities":
return t("city");
case "areas":
return t("area");
}
};
const handleAllSelect = () => {
switch (currentView) {
case "countries":
resetCityData();
handleSubmitLocation();
OnHide();
break;
case "states":
saveCity({
city: "",
state: "",
country: selectedLocation?.country?.name,
lat: selectedLocation?.country?.latitude,
long: selectedLocation?.country?.longitude,
formattedAddress: selectedLocation?.country?.translated_name,
});
handleSubmitLocation();
OnHide();
break;
case "cities":
saveCity({
city: "",
state: selectedLocation?.state?.name,
country: selectedLocation?.country?.name,
lat: selectedLocation?.state?.latitude,
long: selectedLocation?.state?.longitude,
formattedAddress: [
selectedLocation?.state?.translated_name,
selectedLocation?.country?.translated_name,
]
.filter(Boolean)
.join(", "),
});
handleSubmitLocation();
OnHide();
break;
case "areas":
saveCity({
city: selectedLocation?.city?.name,
state: selectedLocation?.state?.name,
country: selectedLocation?.country?.name,
lat: selectedLocation?.city?.latitude,
long: selectedLocation?.city?.longitude,
formattedAddress: [
selectedLocation?.city?.translated_name,
selectedLocation?.state?.translated_name,
selectedLocation?.country?.translated_name,
]
.filter(Boolean)
.join(", "),
});
handleSubmitLocation();
OnHide();
break;
}
};
const getAllButtonTitle = () => {
switch (currentView) {
case "countries":
return t("allCountries");
case "states":
return `${t("allIn")} ${selectedLocation.country?.translated_name}`;
case "cities":
return `${t("allIn")} ${selectedLocation.state?.translated_name}`;
case "areas":
return `${t("allIn")} ${selectedLocation.city?.translated_name}`;
}
};
const getCurrentLocationUsingFreeApi = async (latitude, longitude) => {
try {
const data = await fetchLocationData({ lat: latitude, lng: longitude });
setSelectedCity(data);
setIsMapLocation(true);
setLocationStatus(null);
} catch (error) {
console.log(error);
setLocationStatus("error");
}
};
const getCurrentLocation = () => {
if (navigator.geolocation) {
setLocationStatus("fetching");
navigator.geolocation.getCurrentPosition(
async (position) => {
const { latitude, longitude } = position.coords;
await getCurrentLocationUsingFreeApi(latitude, longitude);
},
(error) => {
console.error("Geolocation error:", error);
if (error.code === error.PERMISSION_DENIED) {
setLocationStatus("denied");
} else {
setLocationStatus("error");
}
}
);
} else {
toast.error(t("geoLocationNotSupported"));
}
};
return (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-xl ltr:text-left rtl:text-right">
{currentView !== "countries" && (
<button onClick={handleBack}>
<MdArrowBack size={20} className="rtl:scale-x-[-1]" />
</button>
)}
{getFormattedLocation()}
</DialogTitle>
</DialogHeader>
{currentView === "countries" ? (
<div className="flex items-center gap-2 border rounded-sm relative">
<SearchAutocomplete saveOnSuggestionClick={true} OnHide={OnHide} />
</div>
) : (
<div className="flex items-center gap-2 border rounded-sm relative p-3 ltr:pl-9 rtl:pr-9">
<SearchIcon className="size-4 shrink-0 absolute left-3 text-muted-foreground" />
<input
type="text"
className="w-full outline-none text-sm"
placeholder={getPlaceholderText()}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div >
)}
{/* Current Location Wrapper */}
{
IsBrowserSupported && (
<div className="flex items-center gap-2 border rounded p-2 px-4">
<BiCurrentLocation className="text-primary size-5 shrink-0" />
<button
className="flex flex-col items-start ltr:text-left rtl:text-right"
disabled={false}
onClick={getCurrentLocation}
>
<p className="text-sm font-medium text-primary">
{t("useCurrentLocation")}
</p>
<p className="text-xs font-normal m-0 text-gray-500">
{locationStatus === "fetching"
? t("gettingLocation")
: locationStatus === "denied"
? t("locationPermissionDenied")
: locationStatus === "error"
? t("error")
: t("automaticallyDetectLocation")}
</p>
</button>
</div>
)
}
<div className="border border-sm rounded-sm">
<button
className="flex items-center gap-1 p-3 text-sm font-medium justify-between w-full border-b ltr:text-left rtl:text-right"
onClick={handleAllSelect}
>
<span>{getAllButtonTitle()}</span>
<div className="bg-muted rounded-sm">
<MdOutlineKeyboardArrowRight
size={20}
className="rtl:scale-x-[-1]"
/>
</div>
</button>
<div className="overflow-y-auto h-[300px]">
{locationData.isLoading ? (
<PlacesSkeleton />
) : (
<>
{locationData.items.length > 0 ? (
locationData.items.map((item, index) => (
<button
className={cn(
"flex items-center ltr:text-left rtl:text-right gap-1 p-3 text-sm font-medium justify-between w-full",
index !== locationData.length - 1 && "border-b"
)}
onClick={() => handleItemSelect(item)}
key={item?.id}
ref={
index === locationData.items.length - 1 &&
locationData.hasMore
? ref
: null
}
>
<span>{item?.translated_name || item?.name}</span>
<div className="bg-muted rounded-sm">
<MdOutlineKeyboardArrowRight
size={20}
className="rtl:scale-x-[-1]"
/>
</div>
</button>
))
) : (
<NoData name={getTitle()} />
)}
{locationData.isLoadMore && (
<div className="p-4 flex justify-center">
<Loader2 className="size-4 animate-spin" />
</div>
)}
</>
)}
</div>
</div>
</>
);
};
export default LocationSelector;
const PlacesSkeleton = () => {
return (
<div className="space-y-2">
{Array.from({ length: 8 }).map((_, index) => (
<div key={index} className="flex items-center gap-3 p-3">
<Skeleton className="h-4 w-4 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-[85%]" />
<Skeleton className="h-3 w-[60%]" />
</div>
<Skeleton className="h-5 w-5 rounded-sm" />
</div>
))}
</div>
);
};

View File

@@ -0,0 +1,61 @@
"use client";
import { useEffect } from "react";
import L from "leaflet";
import { MapContainer, Marker, TileLayer } from "react-leaflet";
import "leaflet/dist/leaflet.css";
// Fix Leaflet default marker icon issue
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl:
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png",
iconUrl:
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png",
shadowUrl:
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png",
});
const Map = ({ latitude, longitude }) => {
const containerStyle = {
width: "100%",
height: "200px",
zIndex: 0,
};
// Validate latitude and longitude
const lat = parseFloat(latitude);
const lng = parseFloat(longitude);
// Check if coordinates are valid numbers and within valid ranges
const isValidLat = !isNaN(lat) && lat >= -90 && lat <= 90;
const isValidLng = !isNaN(lng) && lng >= -180 && lng <= 180;
// Use default coordinates if invalid (you can change these to your preferred default location)
const center = [isValidLat ? lat : 0, isValidLng ? lng : 0];
// Don't render marker if coordinates are invalid
const shouldShowMarker = isValidLat && isValidLng;
useEffect(() => {}, [shouldShowMarker]);
return (
shouldShowMarker && (
<MapContainer
style={containerStyle}
center={center}
zoom={10}
scrollWheelZoom={false}
zoomControl={false}
attributionControl={false}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Marker position={center} />
</MapContainer>
)
);
};
export default Map;

View File

@@ -0,0 +1,193 @@
import { DialogTitle } from "@radix-ui/react-dialog";
import { DialogFooter, DialogHeader } from "../ui/dialog";
import { t } from "@/utils";
import { MdArrowBack } from "react-icons/md";
import SearchAutocomplete from "./SearchAutocomplete";
import { BiCurrentLocation } from "react-icons/bi";
import { useState } from "react";
import { getMaxRange, getMinRange } from "@/redux/reducer/settingSlice";
import { useDispatch, useSelector } from "react-redux";
import {
getIsBrowserSupported,
getKilometerRange,
resetCityData,
saveCity,
setKilometerRange,
} from "@/redux/reducer/locationSlice";
import { Slider } from "../ui/slider";
import dynamic from "next/dynamic";
import { Button } from "../ui/button";
import { toast } from "sonner";
import { usePathname } from "next/navigation";
import { CurrentLanguageData, getIsRtl } from "@/redux/reducer/languageSlice";
import { useNavigate } from "../Common/useNavigate";
import useGetLocation from "../Layout/useGetLocation";
const GetLocationWithMap = dynamic(() => import("./GetLocationWithMap"), {
ssr: false,
loading: () => <div className="w-full h-[300px] bg-gray-100 rounded-lg" />,
});
const MapLocation = ({
OnHide,
selectedCity,
setSelectedCity,
setIsMapLocation,
IsPaidApi,
}) => {
const CurrentLanguage = useSelector(CurrentLanguageData);
const dispatch = useDispatch();
const { navigate } = useNavigate();
const pathname = usePathname();
const radius = useSelector(getKilometerRange);
const [KmRange, setKmRange] = useState(radius || 0);
const [IsFetchingLocation, setIsFetchingLocation] = useState(false);
const min_range = useSelector(getMinRange);
const max_range = useSelector(getMaxRange);
const IsBrowserSupported = useSelector(getIsBrowserSupported);
const isRTL = useSelector(getIsRtl);
const { fetchLocationData } = useGetLocation();
const getCurrentLocation = async () => {
if (navigator.geolocation) {
setIsFetchingLocation(true);
navigator.geolocation.getCurrentPosition(
async (position) => {
try {
const { latitude, longitude } = position.coords;
const data = await fetchLocationData({ lat: latitude, lng: longitude });
setSelectedCity(data);
} catch (error) {
console.error("Error fetching location data:", error);
toast.error(t("errorOccurred"));
} finally {
setIsFetchingLocation(false);
}
},
(error) => {
console.log(error);
toast.error(t("locationNotGranted"));
setIsFetchingLocation(false);
}
);
} else {
toast.error(t("geoLocationNotSupported"));
}
};
const getLocationWithMap = async (pos) => {
try {
const data = await fetchLocationData(pos);
setSelectedCity(data);
} catch (error) {
console.error("Error fetching location data:", error);
}
};
const handleSave = () => {
const isInvalidLocation = !selectedCity?.lat || !selectedCity?.long;
if (isInvalidLocation) {
toast.error(t("pleaseSelectLocation"));
return;
}
dispatch(setKilometerRange(KmRange));
saveCity(selectedCity);
toast.success(t("locationSaved"));
OnHide();
// avoid redirect if already on home page otherwise router.push triggering server side api calls
if (pathname !== "/") {
navigate("/");
}
};
const handleReset = () => {
resetCityData();
min_range > 0
? dispatch(setKilometerRange(min_range))
: dispatch(setKilometerRange(0));
OnHide();
// avoid redirect if already on home page otherwise router.push triggering server side api calls
if (pathname !== "/") {
navigate("/");
}
};
return (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 font-semibold text-xl">
{!IsPaidApi && (
<button onClick={() => setIsMapLocation(false)}>
<MdArrowBack size={20} className="rtl:scale-x-[-1]" />
</button>
)}
{selectedCity?.address_translated ||
selectedCity?.formattedAddress ? (
<p>
{selectedCity?.address_translated ||
selectedCity?.formattedAddress}
</p>
) : (
t("addYourAddress")
)}
</DialogTitle>
</DialogHeader>
<div className="flex items-center border rounded-md">
<div className="flex-[3] sm:flex-[2]">
<SearchAutocomplete
saveOnSuggestionClick={false}
OnHide={OnHide}
setSelectedLocation={setSelectedCity}
/>
</div>
{IsBrowserSupported && (
<>
<div className="border-r h-full" />
<button
className="flex-1 flex items-center justify-center"
onClick={getCurrentLocation}
>
<div className="flex items-center gap-2 py-2 px-4">
<BiCurrentLocation className="size-5 shrink-0" />
<span className="text-sm text-balance hidden sm:inline">
{IsFetchingLocation
? t("gettingLocation")
: t("currentLocation")}
</span>
</div>
</button>
</>
)}
</div>
<GetLocationWithMap
KmRange={KmRange}
position={{ lat: selectedCity?.lat, lng: selectedCity?.long }}
getLocationWithMap={getLocationWithMap}
/>
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2 justify-between">
<span>{t("rangeLabel")}</span>
<span>{KmRange} KM</span>
</div>
<Slider
value={[KmRange]}
onValueChange={(value) => setKmRange(value[0])}
max={max_range}
min={min_range}
step={1}
dir={isRTL ? "rtl" : "ltr"}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleReset}>
{t("reset")}
</Button>
<Button onClick={handleSave}>{t("save")}</Button>
</DialogFooter>
</>
);
};
export default MapLocation;

View File

@@ -0,0 +1,248 @@
import { saveCity } from "@/redux/reducer/locationSlice";
import { getIsPaidApi } from "@/redux/reducer/settingSlice";
import { t } from "@/utils";
import { getLocationApi } from "@/utils/api";
import { usePathname } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useSelector } from "react-redux";
import { useDebounce } from "use-debounce";
import { useNavigate } from "../Common/useNavigate";
import { MapPin } from "lucide-react";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
const SearchAutocomplete = ({
saveOnSuggestionClick,
OnHide,
setSelectedLocation,
}) => {
const isSuggestionClick = useRef(false);
const IsPaidApi = useSelector(getIsPaidApi);
const { navigate } = useNavigate();
const pathname = usePathname();
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebounce(search, 500);
const [autoState, setAutoState] = useState({
suggestions: [],
loading: false,
show: false,
});
const sessionTokenRef = useRef(null);
// Generate a new session token (UUID v4)
const generateSessionToken = () => {
// Use crypto.randomUUID() if available (modern browsers)
// Fallback to a simple UUID generator
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID();
}
// Fallback UUID generator
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
/[xy]/g,
function (c) {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
}
);
};
// Fetch suggestions
useEffect(() => {
const fetchSuggestions = async () => {
if (isSuggestionClick.current) {
isSuggestionClick.current = false;
return;
}
if (debouncedSearch && debouncedSearch.length > 1) {
setAutoState((prev) => ({ ...prev, loading: true, show: true }));
try {
// Generate new session token for new search session
// Only generate if we don't have one or if search changed significantly
if (!sessionTokenRef.current) {
sessionTokenRef.current = generateSessionToken();
}
const response = await getLocationApi.getLocation({
search: debouncedSearch,
lang: "en",
// Only include sessiontoken for Google Places API (IsPaidApi)
...(IsPaidApi && { session_id: sessionTokenRef.current }),
});
if (IsPaidApi) {
const results = response?.data?.data?.predictions || [];
setAutoState({ suggestions: results, loading: false, show: true });
} else {
const results = response?.data?.data || [];
const formattedResults = results.map((result) => ({
description: [
result?.area_translation,
result?.city_translation,
result?.state_translation,
result?.country_translation,
]
.filter(Boolean)
.join(", "),
original: result,
}));
setAutoState({
suggestions: formattedResults,
loading: false,
show: true,
});
}
} catch (error) {
console.log("error", error);
setAutoState({ suggestions: [], loading: false, show: true });
}
} else {
// Reset session token when search is cleared
sessionTokenRef.current = null;
setAutoState({ suggestions: [], loading: false, show: false });
}
};
fetchSuggestions();
}, [debouncedSearch, IsPaidApi]);
const handleSuggestionClick = async (suggestion) => {
isSuggestionClick.current = true;
if (IsPaidApi) {
// Use the same session token from autocomplete request
// This groups autocomplete + place details into one billing session
const response = await getLocationApi.getLocation({
place_id: suggestion.place_id,
lang: "en",
// Use the same session token from autocomplete (only if it exists)
...(sessionTokenRef.current && {
session_id: sessionTokenRef.current,
}),
});
const result = response?.data?.data?.results?.[0];
const addressComponents = result.address_components || [];
const getAddressComponent = (type) => {
const component = addressComponents.find((comp) =>
comp.types.includes(type)
);
return component?.long_name || "";
};
const city = getAddressComponent("locality");
const state = getAddressComponent("administrative_area_level_1");
const country = getAddressComponent("country");
const data = {
lat: result?.geometry?.location?.lat,
long: result?.geometry?.location?.lng,
city,
state,
country,
formattedAddress: suggestion?.description,
};
setSearch(suggestion?.description || "");
setAutoState({ suggestions: [], loading: false, show: false });
// Reset session token after place details request (session complete)
sessionTokenRef.current = null;
if (saveOnSuggestionClick) {
saveCity(data);
OnHide?.();
// avoid redirect if already on home page otherwise router.push triggering server side api calls
if (pathname !== "/") {
navigate("/");
}
} else {
setSelectedLocation(data);
}
} else {
const original = suggestion.original;
const data = {
lat: original?.latitude,
long: original?.longitude,
city: original?.city || "",
state: original?.state || "",
country: original?.country || "",
formattedAddress: suggestion.description || "",
area: original?.area || "",
areaId: original?.area_id || "",
};
setSearch(suggestion?.description || "");
setAutoState({ suggestions: [], loading: false, show: false });
if (saveOnSuggestionClick) {
saveCity(data);
OnHide?.();
// avoid redirect if already on home page otherwise router.push triggering server side api calls
if (pathname !== "/") {
navigate("/");
}
} else {
setSelectedLocation(data);
}
}
};
return (
<>
<div className="relative w-full">
<Command
shouldFilter={false} // VERY IMPORTANT
>
<CommandInput
placeholder={t("selectLocation")}
value={search}
onValueChange={(value) => {
setSearch(value);
if (!sessionTokenRef.current) {
sessionTokenRef.current = generateSessionToken();
}
}}
onFocus={() => {
if (autoState.suggestions.length > 0) {
setAutoState((p) => ({ ...p, show: true }));
}
}}
wrapperClassName="border-b-0"
/>
{autoState.show &&
(autoState.suggestions.length > 0 || autoState.loading) && (
<CommandList className="absolute top-full left-0 right-0 z-[1500] max-h-[220px] overflow-y-auto rounded-lg border bg-white shadow-lg">
{autoState.loading && (
<CommandEmpty>{t("loading")}</CommandEmpty>
)}
<CommandGroup>
{autoState.suggestions.map((s, idx) => (
<CommandItem
key={idx}
value={s.description}
onSelect={() => {
handleSuggestionClick(s);
setAutoState((p) => ({ ...p, show: false }));
}}
>
<MapPin size={16} className="flex-shrink-0" />
<span className="text-sm">
{s.description || "Unknown"}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
)}
</Command>
</div>
</>
);
};
export default SearchAutocomplete;