classify web
This commit is contained in:
51
components/Filter/AreaNode.jsx
Normal file
51
components/Filter/AreaNode.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { setSelectedLocation } from "@/redux/reducer/globalStateSlice";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
const AreaNode = ({ area, city, state, country }) => {
|
||||
const dispatch = useDispatch();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const selectedAreaId = searchParams.get("areaId") || "";
|
||||
|
||||
const isSelected = Number(selectedAreaId) === Number(area.id);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelected) {
|
||||
dispatch(setSelectedLocation(area));
|
||||
}
|
||||
}, [isSelected, area]);
|
||||
|
||||
const handleClick = () => {
|
||||
const newSearchParams = new URLSearchParams(searchParams);
|
||||
newSearchParams.set("areaId", area?.id?.toString());
|
||||
newSearchParams.set("area", area?.name);
|
||||
newSearchParams.set("lat", area?.latitude?.toString());
|
||||
newSearchParams.set("lng", area?.longitude?.toString());
|
||||
newSearchParams.set("country", country?.name);
|
||||
newSearchParams.set("state", state?.name);
|
||||
newSearchParams.set("city", city?.name);
|
||||
|
||||
window.history.pushState(null, '', `/ads?${newSearchParams.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<li>
|
||||
<div className="rounded">
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"flex-1 ltr:text-left rtl:text-right py-1 px-2 rounded-sm",
|
||||
isSelected && "border bg-muted"
|
||||
)}
|
||||
>
|
||||
{area.translated_name || area?.name}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default AreaNode;
|
||||
59
components/Filter/BudgetFilter.jsx
Normal file
59
components/Filter/BudgetFilter.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useState } from "react";
|
||||
import { Input } from "../ui/input";
|
||||
import { Button } from "../ui/button";
|
||||
import { t } from "@/utils";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
const BudgetFilter = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const [budget, setBudget] = useState({
|
||||
minPrice: searchParams.get("min_price") || "",
|
||||
maxPrice: searchParams.get("max_price") || "",
|
||||
});
|
||||
|
||||
const { minPrice, maxPrice } = budget;
|
||||
|
||||
const handleMinMaxPrice = () => {
|
||||
const newSearchParams = new URLSearchParams(searchParams);
|
||||
newSearchParams.set("min_price", minPrice);
|
||||
newSearchParams.set("max_price", maxPrice);
|
||||
window.history.pushState(null, "", `/ads?${newSearchParams.toString()}`);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 mt-4">
|
||||
<form className="flex gap-4">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t("from")}
|
||||
min={0}
|
||||
onChange={(e) =>
|
||||
setBudget((prev) => ({ ...prev, minPrice: Number(e.target.value) }))
|
||||
}
|
||||
value={minPrice}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t("to")}
|
||||
min={0}
|
||||
onChange={(e) =>
|
||||
setBudget((prev) => ({ ...prev, maxPrice: Number(e.target.value) }))
|
||||
}
|
||||
value={maxPrice}
|
||||
/>
|
||||
</form>
|
||||
<Button
|
||||
type="submit"
|
||||
className="hover:bg-primary hover:text-white"
|
||||
variant="outline"
|
||||
disabled={minPrice == null || maxPrice == null || minPrice >= maxPrice}
|
||||
onClick={handleMinMaxPrice}
|
||||
>
|
||||
{t("apply")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BudgetFilter;
|
||||
145
components/Filter/CategoryNode.jsx
Normal file
145
components/Filter/CategoryNode.jsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BreadcrumbPathData } from "@/redux/reducer/breadCrumbSlice";
|
||||
import { t } from "@/utils";
|
||||
import { categoryApi } from "@/utils/api";
|
||||
import { Loader2, Minus, Plus } from "lucide-react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useNavigate } from "../Common/useNavigate";
|
||||
|
||||
const CategoryNode = ({ category, extraDetails, setExtraDetails }) => {
|
||||
const { navigate } = useNavigate();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [subcategories, setSubcategories] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const breadcrumbPath = useSelector(BreadcrumbPathData);
|
||||
|
||||
const selectedSlug = searchParams.get("category") || "";
|
||||
const isSelected = category.slug === selectedSlug;
|
||||
|
||||
const shouldExpand = useMemo(() => {
|
||||
if (!Array.isArray(breadcrumbPath) || breadcrumbPath.length <= 2)
|
||||
return false;
|
||||
// Skip the first (All Categories) and last (leaf node)
|
||||
const keysToCheck = breadcrumbPath.slice(1, -1).map((crumb) => crumb.key);
|
||||
return keysToCheck.includes(category.slug);
|
||||
}, []);
|
||||
|
||||
// 📦 Auto-expand if it's in the path
|
||||
useEffect(() => {
|
||||
if (shouldExpand && !expanded) {
|
||||
// If not already expanded and part of the path, expand and load children
|
||||
setExpanded(true);
|
||||
fetchSubcategories();
|
||||
}
|
||||
}, [shouldExpand]);
|
||||
const fetchSubcategories = async (page = 1, append = false) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await categoryApi.getCategory({
|
||||
category_id: category.id,
|
||||
page,
|
||||
});
|
||||
const data = response.data.data.data;
|
||||
const hasMore =
|
||||
response.data.data.last_page > response.data.data.current_page;
|
||||
setSubcategories((prev) => (append ? [...prev, ...data] : data));
|
||||
setHasMore(hasMore);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleExpand = async () => {
|
||||
if (!expanded && subcategories.length === 0) {
|
||||
await fetchSubcategories();
|
||||
}
|
||||
setExpanded((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
const newSearchParams = new URLSearchParams(searchParams);
|
||||
newSearchParams.set("category", category.slug);
|
||||
Object.keys(extraDetails || {}).forEach((key) => {
|
||||
newSearchParams.delete(key);
|
||||
});
|
||||
|
||||
setExtraDetails({})
|
||||
|
||||
if (pathname.startsWith("/ads")) {
|
||||
window.history.pushState(null, "", `/ads?${newSearchParams.toString()}`);
|
||||
} else {
|
||||
navigate(`/ads?${newSearchParams.toString()}`);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMore = async () => {
|
||||
const nextPage = page + 1;
|
||||
setPage(nextPage);
|
||||
await fetchSubcategories(nextPage, true);
|
||||
};
|
||||
|
||||
return (
|
||||
<li>
|
||||
<div className="flex items-center rounded text-sm">
|
||||
{category.subcategories_count > 0 &&
|
||||
(isLoading ? (
|
||||
<div className="p-1">
|
||||
<Loader2 className="size-[14px] animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="text-sm p-1 hover:bg-muted rounded-sm"
|
||||
onClick={handleToggleExpand}
|
||||
>
|
||||
{expanded ? <Minus size={14} /> : <Plus size={14} />}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"flex-1 ltr:text-left rtl:text-right py-1 px-2 rounded-sm flex items-center justify-between gap-2",
|
||||
isSelected && "border bg-muted"
|
||||
)}
|
||||
>
|
||||
<span className="break-all">{category.translated_name}</span>
|
||||
<span>({category.all_items_count})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<ul className="ltr:ml-3 rtl:mr-3 ltr:border-l rtl:border-r ltr:pl-2 rtl:pr-2 space-y-1">
|
||||
{subcategories.map((sub) => (
|
||||
<CategoryNode
|
||||
key={sub.id + "filter-tree"}
|
||||
category={sub}
|
||||
selectedSlug={selectedSlug}
|
||||
searchParams={searchParams}
|
||||
extraDetails={extraDetails}
|
||||
setExtraDetails={setExtraDetails}
|
||||
/>
|
||||
))}
|
||||
|
||||
{hasMore && (
|
||||
<button
|
||||
onClick={loadMore}
|
||||
className="text-primary text-center text-sm py-1 px-2"
|
||||
>
|
||||
{t("loadMore")}
|
||||
</button>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryNode;
|
||||
158
components/Filter/CityNode.jsx
Normal file
158
components/Filter/CityNode.jsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { Loader2, Minus, Plus } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import AreaNode from "./AreaNode";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getAreasApi } from "@/utils/api";
|
||||
import { t } from "@/utils";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { setSelectedLocation } from "@/redux/reducer/globalStateSlice";
|
||||
|
||||
const CityNode = ({ city, country, state }) => {
|
||||
const dispatch = useDispatch();
|
||||
const searchParams = useSearchParams();
|
||||
const [areas, setAreas] = useState({
|
||||
data: [],
|
||||
currentPage: 1,
|
||||
hasMore: false,
|
||||
isLoading: false,
|
||||
isLoadMore: false,
|
||||
expanded: false,
|
||||
});
|
||||
|
||||
const lat = searchParams.get("lat") || "";
|
||||
const lng = searchParams.get("lng") || "";
|
||||
const selectedCity = searchParams.get("city") || "";
|
||||
const selectedArea = searchParams.get("area") || "";
|
||||
|
||||
const isSelected = useMemo(() => {
|
||||
return city?.latitude === lat && city?.longitude === lng && !selectedArea;
|
||||
}, [lat, lng]);
|
||||
|
||||
const shouldExpand = selectedCity === city?.name && selectedArea;
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelected) {
|
||||
dispatch(setSelectedLocation(city));
|
||||
}
|
||||
}, [isSelected, city]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldExpand && !areas.expanded) {
|
||||
fetchAreas();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchAreas = async (page = 1) => {
|
||||
try {
|
||||
page > 1
|
||||
? setAreas((prev) => ({ ...prev, isLoadMore: true }))
|
||||
: setAreas((prev) => ({ ...prev, isLoading: true }));
|
||||
|
||||
const response = await getAreasApi.getAreas({
|
||||
city_id: city.id,
|
||||
page,
|
||||
});
|
||||
const newData = response?.data?.data?.data ?? [];
|
||||
const currentPage = response?.data?.data?.current_page;
|
||||
const lastPage = response?.data?.data?.last_page;
|
||||
|
||||
setAreas((prev) => ({
|
||||
...prev,
|
||||
data: page > 1 ? [...prev.data, ...newData] : newData,
|
||||
currentPage,
|
||||
hasMore: lastPage > currentPage,
|
||||
expanded: true,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setAreas((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
isLoadMore: false,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleExpand = async () => {
|
||||
if (!areas.expanded && areas.data.length === 0) {
|
||||
await fetchAreas();
|
||||
} else {
|
||||
setAreas((prev) => ({ ...prev, expanded: !prev.expanded }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
const newSearchParams = new URLSearchParams(searchParams);
|
||||
newSearchParams.set("country", country?.name);
|
||||
newSearchParams.set("state", state?.name);
|
||||
newSearchParams.set("city", city?.name);
|
||||
newSearchParams.set("lat", city.latitude);
|
||||
newSearchParams.set("lng", city.longitude);
|
||||
// Always remove unrelated location filters to avoid redundancy
|
||||
newSearchParams.delete("area");
|
||||
newSearchParams.delete("areaId");
|
||||
newSearchParams.delete("km_range");
|
||||
window.history.pushState(null, '', `/ads?${newSearchParams.toString()}`);
|
||||
};
|
||||
|
||||
const loadMore = async () => {
|
||||
await fetchAreas(areas.currentPage + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<li>
|
||||
<div className="flex items-center rounded">
|
||||
{areas?.isLoading ? (
|
||||
<Loader2 className="size-[14px] animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
city.areas_count > 0 && (
|
||||
<button
|
||||
className="text-sm p-1 hover:bg-muted rounded-sm"
|
||||
onClick={handleToggleExpand}
|
||||
>
|
||||
{areas.expanded ? <Minus size={14} /> : <Plus size={14} />}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"flex-1 ltr:text-left rtl:text-right py-1 px-2 rounded-sm break-all",
|
||||
isSelected && "border bg-muted"
|
||||
)}
|
||||
>
|
||||
{city.translated_name || city?.name}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{areas.expanded && (
|
||||
<ul className="ltr:ml-3 rtl:mr-3 ltr:border-l rtl:border-r ltr:pl-2 rtl:pr-2 space-y-1">
|
||||
{areas.data.map((area) => (
|
||||
<AreaNode
|
||||
key={area.id}
|
||||
area={area}
|
||||
city={city}
|
||||
state={state}
|
||||
country={country}
|
||||
/>
|
||||
))}
|
||||
|
||||
{areas.hasMore && (
|
||||
<button
|
||||
onClick={loadMore}
|
||||
className="text-primary text-center text-sm py-1 px-2"
|
||||
disabled={areas.isLoadMore}
|
||||
>
|
||||
{areas.isLoadMore ? t("loading") : t("loadMore")}
|
||||
</button>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default CityNode;
|
||||
160
components/Filter/CountryNode.jsx
Normal file
160
components/Filter/CountryNode.jsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getStatesApi } from "@/utils/api";
|
||||
import { Loader2, Minus, Plus } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import StateNode from "./StateNode";
|
||||
import { t } from "@/utils";
|
||||
import { setSelectedLocation } from "@/redux/reducer/globalStateSlice";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
const CountryNode = ({ country }) => {
|
||||
const dispatch = useDispatch();
|
||||
const searchParams = useSearchParams();
|
||||
const [states, setStates] = useState({
|
||||
data: [],
|
||||
currentPage: 1,
|
||||
hasMore: false,
|
||||
isLoading: false,
|
||||
isLoadMore: false,
|
||||
expanded: false,
|
||||
});
|
||||
|
||||
const lat = searchParams.get("lat") || "";
|
||||
const lng = searchParams.get("lng") || "";
|
||||
|
||||
const selectedState = searchParams.get("state") || "";
|
||||
const selectedCity = searchParams.get("city") || "";
|
||||
const selectedArea = searchParams.get("area") || "";
|
||||
const selectedCountry = searchParams.get("country") || "";
|
||||
|
||||
const isSelected = useMemo(() => {
|
||||
return (
|
||||
country?.latitude === lat &&
|
||||
country?.longitude === lng &&
|
||||
!selectedState &&
|
||||
!selectedCity &&
|
||||
!selectedArea
|
||||
);
|
||||
}, [lat, lng]);
|
||||
|
||||
const shouldExpand = selectedCountry === country?.name && selectedState;
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldExpand && !states.expanded) {
|
||||
fetchStates();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelected) {
|
||||
dispatch(setSelectedLocation(country));
|
||||
}
|
||||
}, [isSelected, country]);
|
||||
|
||||
const fetchStates = async (page = 1) => {
|
||||
try {
|
||||
page > 1
|
||||
? setStates((prev) => ({ ...prev, isLoadMore: true }))
|
||||
: setStates((prev) => ({ ...prev, isLoading: true }));
|
||||
|
||||
const response = await getStatesApi.getStates({
|
||||
country_id: country.id,
|
||||
page,
|
||||
});
|
||||
const newData = response?.data?.data?.data ?? [];
|
||||
const currentPage = response?.data?.data?.current_page;
|
||||
const lastPage = response?.data?.data?.last_page;
|
||||
|
||||
setStates((prev) => ({
|
||||
...prev,
|
||||
data: page > 1 ? [...prev.data, ...newData] : newData,
|
||||
currentPage,
|
||||
hasMore: lastPage > currentPage,
|
||||
expanded: true,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setStates((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
isLoadMore: false,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleExpand = async () => {
|
||||
if (!states.expanded && states.data.length === 0) {
|
||||
await fetchStates();
|
||||
} else {
|
||||
setStates((prev) => ({ ...prev, expanded: !prev.expanded }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
const newSearchParams = new URLSearchParams(searchParams);
|
||||
newSearchParams.set("country", country?.name);
|
||||
newSearchParams.set("lat", country.latitude);
|
||||
newSearchParams.set("lng", country.longitude);
|
||||
newSearchParams.delete("state");
|
||||
newSearchParams.delete("city");
|
||||
newSearchParams.delete("area");
|
||||
newSearchParams.delete("areaId");
|
||||
newSearchParams.delete("km_range");
|
||||
window.history.pushState(null, '', `/ads?${newSearchParams.toString()}`);
|
||||
};
|
||||
|
||||
const loadMore = async () => {
|
||||
await fetchStates(states.currentPage + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<li>
|
||||
<div className="flex items-center rounded">
|
||||
{states?.isLoading ? (
|
||||
<Loader2 className="size-[14px] animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
country.states_count > 0 && (
|
||||
<button
|
||||
className="text-sm p-1 hover:bg-muted rounded-sm"
|
||||
onClick={handleToggleExpand}
|
||||
>
|
||||
{states.expanded ? <Minus size={14} /> : <Plus size={14} />}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"flex-1 ltr:text-left rtl:text-right py-1 px-2 rounded-sm",
|
||||
isSelected && "border bg-muted"
|
||||
)}
|
||||
>
|
||||
{country?.translated_name || country?.name}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{states.expanded && (
|
||||
<ul className="ltr:ml-3 rtl:mr-3 ltr:border-l rtl:border-r ltr:pl-2 rtl:pr-2 space-y-1">
|
||||
{states.data.map((state) => (
|
||||
<StateNode key={state.id} state={state} country={country} />
|
||||
))}
|
||||
|
||||
{states.hasMore && (
|
||||
<button
|
||||
onClick={loadMore}
|
||||
className="text-primary text-center text-sm py-1 px-2"
|
||||
disabled={states.isLoadMore}
|
||||
>
|
||||
{states.isLoadMore ? t("loading") : t("loadMore")}
|
||||
</button>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default CountryNode;
|
||||
63
components/Filter/DatePostedFilter.jsx
Normal file
63
components/Filter/DatePostedFilter.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Checkbox } from "../ui/checkbox";
|
||||
import { t } from "@/utils";
|
||||
|
||||
const DatePostedFilter = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const value = searchParams.get("date_posted") || "";
|
||||
|
||||
const datesPostedOptions = [
|
||||
{
|
||||
label: "allTime",
|
||||
value: "all-time",
|
||||
},
|
||||
{
|
||||
label: "today",
|
||||
value: "today",
|
||||
},
|
||||
{
|
||||
label: "within1Week",
|
||||
value: "within-1-week",
|
||||
},
|
||||
{
|
||||
label: "within2Weeks",
|
||||
value: "within-2-week",
|
||||
},
|
||||
{
|
||||
label: "within1Month",
|
||||
value: "within-1-month",
|
||||
},
|
||||
{
|
||||
label: "within3Months",
|
||||
value: "within-3-month",
|
||||
},
|
||||
];
|
||||
|
||||
const handleCheckboxChange = (optionValue) => {
|
||||
const newSearchParams = new URLSearchParams(searchParams);
|
||||
if (value === optionValue) {
|
||||
// Uncheck: remove the filter
|
||||
newSearchParams.delete("date_posted");
|
||||
} else {
|
||||
// Check: set the filter
|
||||
newSearchParams.set("date_posted", optionValue);
|
||||
}
|
||||
window.history.pushState(null, '', `/ads?${newSearchParams.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{datesPostedOptions.map((option) => (
|
||||
<div className="flex items-center gap-2" key={option.value}>
|
||||
<Checkbox
|
||||
checked={value === option.value}
|
||||
onCheckedChange={() => handleCheckboxChange(option.value)}
|
||||
/>
|
||||
<label>{t(option.label)}</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DatePostedFilter;
|
||||
155
components/Filter/ExtraDetailsFilter.jsx
Normal file
155
components/Filter/ExtraDetailsFilter.jsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { Fragment } from "react";
|
||||
import { Checkbox } from "../ui/checkbox";
|
||||
import { Label } from "../ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { t } from "@/utils";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
const ExtraDetailsFilter = ({
|
||||
customFields,
|
||||
extraDetails,
|
||||
setExtraDetails,
|
||||
newSearchParams,
|
||||
}) => {
|
||||
|
||||
const isApplyDisabled = () => {
|
||||
return !Object.values(extraDetails).some(
|
||||
(val) => (Array.isArray(val) && val.length > 0) || (!!val && val !== "")
|
||||
);
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (id, value, checked) => {
|
||||
setExtraDetails((prev) => {
|
||||
const existing = prev[id] || [];
|
||||
const updated = checked
|
||||
? [...existing, value]
|
||||
: existing.filter((v) => v !== value);
|
||||
return { ...prev, [id]: updated.length ? updated : "" };
|
||||
});
|
||||
};
|
||||
|
||||
const handleInputChange = (fieldId, value) => {
|
||||
setExtraDetails((prev) => ({
|
||||
...prev,
|
||||
[fieldId]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
Object.entries(extraDetails).forEach(([key, val]) => {
|
||||
if (Array.isArray(val) && val.length) {
|
||||
newSearchParams.set(key, val.join(","));
|
||||
} else if (val) {
|
||||
newSearchParams.set(key, val);
|
||||
} else {
|
||||
newSearchParams.delete(key);
|
||||
}
|
||||
});
|
||||
window.history.pushState(null, '', `/ads?${newSearchParams.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 flex-col">
|
||||
{customFields.map((field) => (
|
||||
<Fragment key={field.id}>
|
||||
{/* Checkbox */}
|
||||
{field.type === "checkbox" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="font-semibold" htmlFor={field.id}>
|
||||
{field.translated_name || field.name}
|
||||
</Label>
|
||||
{field.values.map((option, index) => (
|
||||
<div key={option} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`${field.id}-${option}`}
|
||||
checked={(extraDetails[field.id] || []).includes(option)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleCheckboxChange(field.id, option, checked)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`${field.id}-${option}`}
|
||||
className="text-sm cursor-pointer"
|
||||
>
|
||||
{field?.translated_value[index] || option}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Radio */}
|
||||
{field.type === "radio" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="font-semibold" htmlFor={field.id}>
|
||||
{field.translated_name || field.name}
|
||||
</Label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{field.values.map((option, index) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
className={`py-2 px-4 w-fit rounded-md border transition-colors ${
|
||||
extraDetails[field.id] === option
|
||||
? "bg-primary text-white"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => handleInputChange(field.id, option)}
|
||||
aria-pressed={extraDetails[field.id] === option}
|
||||
>
|
||||
{field?.translated_value[index] || option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dropdown */}
|
||||
{field.type === "dropdown" && (
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<Label className="font-semibold capitalize" htmlFor={field.id}>
|
||||
{field.translated_name || field.name}
|
||||
</Label>
|
||||
<Select
|
||||
value={extraDetails[field.id] || ""}
|
||||
onValueChange={(val) => handleInputChange(field.id, val)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={`${t("select")} ${
|
||||
field.translated_name || field.name
|
||||
}`}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.values.map((option, index) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{field?.translated_value[index] || option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hover:bg-primary hover:text-white"
|
||||
onClick={handleApply}
|
||||
disabled={isApplyDisabled()}
|
||||
>
|
||||
{t("apply")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExtraDetailsFilter;
|
||||
118
components/Filter/Filter.jsx
Normal file
118
components/Filter/Filter.jsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import FilterTree from "./FilterTree";
|
||||
import { t } from "@/utils";
|
||||
import { useSelector } from "react-redux";
|
||||
import { getCurrentLangCode } from "@/redux/reducer/languageSlice";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import LocationTree from "./LocationTree";
|
||||
import BudgetFilter from "./BudgetFilter";
|
||||
import DatePostedFilter from "./DatePostedFilter";
|
||||
import RangeFilter from "./RangeFilter";
|
||||
import ExtraDetailsFilter from "./ExtraDetailsFilter";
|
||||
|
||||
const Filter = ({
|
||||
customFields,
|
||||
extraDetails,
|
||||
setExtraDetails,
|
||||
newSearchParams,
|
||||
country,
|
||||
state,
|
||||
city,
|
||||
area,
|
||||
}) => {
|
||||
const langId = useSelector(getCurrentLangCode);
|
||||
const isShowCustomfieldFilter =
|
||||
customFields &&
|
||||
customFields.length > 0 &&
|
||||
customFields.some(
|
||||
(field) =>
|
||||
field.type === "checkbox" ||
|
||||
field.type === "radio" ||
|
||||
field.type === "dropdown"
|
||||
);
|
||||
|
||||
const isLocationSelected = country || state || city || area;
|
||||
|
||||
return (
|
||||
<div className="w-full border rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-2 font-semibold border-b text-xl">
|
||||
{t("filters")}
|
||||
</div>
|
||||
<div className=" flex flex-col ">
|
||||
<Accordion
|
||||
type="multiple"
|
||||
defaultValue={
|
||||
isLocationSelected ? ["location", "category"] : ["category"]
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
<AccordionItem value="category" className="border-b">
|
||||
<AccordionTrigger className="p-4">
|
||||
<span className="font-semibold text-base">{t("category")}</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-4 pb-4">
|
||||
<FilterTree key={langId} extraDetails={extraDetails} setExtraDetails={setExtraDetails} />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="location" className="border-b">
|
||||
<AccordionTrigger className="p-4">
|
||||
<span className="font-semibold text-base">{t("location")}</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-4 pb-4">
|
||||
<LocationTree />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="budget" className="border-b">
|
||||
<AccordionTrigger className="p-4">
|
||||
<span className="font-semibold text-base">{t("budget")}</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-4 pb-4">
|
||||
<BudgetFilter />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="date-posted" className="border-b">
|
||||
<AccordionTrigger className="p-4">
|
||||
<span className="font-semibold text-base">{t("datePosted")}</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-4 pb-4">
|
||||
<DatePostedFilter />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="nearby-range" className="border-b">
|
||||
<AccordionTrigger className="p-4">
|
||||
<span className="font-semibold text-base">
|
||||
{t("nearByKmRange")}
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-4 pb-4">
|
||||
<RangeFilter />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
{isShowCustomfieldFilter && (
|
||||
<AccordionItem value="extra-details">
|
||||
<AccordionTrigger className="p-4">
|
||||
<span className="font-semibold text-base">
|
||||
{t("extradetails")}
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-4 pb-4">
|
||||
<ExtraDetailsFilter
|
||||
customFields={customFields}
|
||||
extraDetails={extraDetails}
|
||||
setExtraDetails={setExtraDetails}
|
||||
newSearchParams={newSearchParams}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Filter;
|
||||
104
components/Filter/FilterTree.jsx
Normal file
104
components/Filter/FilterTree.jsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Loader2, Minus, Plus } from "lucide-react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { t } from "@/utils";
|
||||
import CategoryNode from "./CategoryNode";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "../Common/useNavigate";
|
||||
import useGetCategories from "../Layout/useGetCategories";
|
||||
|
||||
const FilterTree = ({ extraDetails, setExtraDetails }) => {
|
||||
const { navigate } = useNavigate();
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
getCategories,
|
||||
cateData,
|
||||
isCatLoading,
|
||||
isCatLoadMore,
|
||||
catCurrentPage,
|
||||
catLastPage,
|
||||
} = useGetCategories();
|
||||
const hasMore = catCurrentPage < catLastPage;
|
||||
|
||||
const selectedSlug = searchParams.get("category") || "";
|
||||
const isSelected = !selectedSlug; // "All" category is selected when no category is selected
|
||||
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
const handleToggleExpand = () => {
|
||||
setExpanded((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.delete("category");
|
||||
Object.keys(extraDetails || {})?.forEach((key) => {
|
||||
params.delete(key);
|
||||
});
|
||||
|
||||
setExtraDetails({})
|
||||
|
||||
if (pathname.startsWith("/ads")) {
|
||||
window.history.pushState(null, "", `/ads?${params.toString()}`);
|
||||
} else {
|
||||
navigate(`/ads?${params.toString()}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ul>
|
||||
<li>
|
||||
<div className="flex items-center rounded text-sm">
|
||||
{isCatLoading ? (
|
||||
<div className="p-1">
|
||||
<Loader2 className="size-[14px] animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="text-sm p-1 hover:bg-muted rounded-sm"
|
||||
onClick={handleToggleExpand}
|
||||
>
|
||||
{expanded ? <Minus size={14} /> : <Plus size={14} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"flex-1 ltr:text-left rtl:text-right py-1 px-2 rounded-sm",
|
||||
isSelected && "border bg-muted"
|
||||
)}
|
||||
>
|
||||
{t("allCategories")}
|
||||
</button>
|
||||
</div>
|
||||
{expanded && cateData.length > 0 && (
|
||||
<ul className="ltr:ml-3 rtl:mr-3 ltr:border-l rtl:border-r ltr:pl-2 rtl:pr-2 space-y-1">
|
||||
{cateData.map((category) => (
|
||||
<CategoryNode
|
||||
key={category.id + "filter-tree"}
|
||||
category={category}
|
||||
extraDetails={extraDetails}
|
||||
setExtraDetails={setExtraDetails}
|
||||
/>
|
||||
))}
|
||||
|
||||
{hasMore && (
|
||||
<button
|
||||
onClick={() => getCategories(catCurrentPage + 1)}
|
||||
className="text-primary text-center text-sm py-1 px-2"
|
||||
disabled={isCatLoadMore}
|
||||
>
|
||||
{isCatLoadMore ? t("loading") : t("loadMore")}
|
||||
</button>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterTree;
|
||||
130
components/Filter/LocationTree.jsx
Normal file
130
components/Filter/LocationTree.jsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getCoutriesApi } from "@/utils/api";
|
||||
import { Loader2, Minus, Plus } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { t } from "@/utils";
|
||||
import CountryNode from "./CountryNode";
|
||||
|
||||
const LocationTree = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const langCode = searchParams.get("lang");
|
||||
|
||||
const selectedCountry = searchParams.get("country") || "";
|
||||
const selectedState = searchParams.get("state") || "";
|
||||
const selectedCity = searchParams.get("city") || "";
|
||||
const selectedArea = searchParams.get("area") || "";
|
||||
const isAllSelected =
|
||||
!selectedCountry && !selectedState && !selectedCity && !selectedArea;
|
||||
|
||||
const [countries, setCountries] = useState({
|
||||
data: [],
|
||||
currentPage: 1,
|
||||
hasMore: false,
|
||||
isLoading: false,
|
||||
isLoadMore: false,
|
||||
expanded: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchCountries();
|
||||
}, [langCode]);
|
||||
|
||||
const fetchCountries = async (page = 1) => {
|
||||
try {
|
||||
page > 1
|
||||
? setCountries((prev) => ({ ...prev, isLoadMore: true }))
|
||||
: setCountries((prev) => ({ ...prev, isLoading: true }));
|
||||
|
||||
const response = await getCoutriesApi.getCoutries({ page });
|
||||
const newData = response?.data?.data?.data ?? [];
|
||||
const currentPage = response?.data?.data?.current_page;
|
||||
const lastPage = response?.data?.data?.last_page;
|
||||
|
||||
setCountries((prev) => ({
|
||||
...prev,
|
||||
data: page > 1 ? [...prev.data, ...newData] : newData,
|
||||
currentPage,
|
||||
hasMore: lastPage > currentPage,
|
||||
expanded: true,
|
||||
}));
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setCountries((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
isLoadMore: false,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleExpand = () => {
|
||||
setCountries((prev) => ({ ...prev, expanded: !prev.expanded }));
|
||||
};
|
||||
|
||||
const handleAllLocationsClick = () => {
|
||||
// Clear all location parameters
|
||||
const newSearchParams = new URLSearchParams(searchParams);
|
||||
newSearchParams.delete("country");
|
||||
newSearchParams.delete("state");
|
||||
newSearchParams.delete("city");
|
||||
newSearchParams.delete("area");
|
||||
newSearchParams.delete("lat");
|
||||
newSearchParams.delete("lng");
|
||||
newSearchParams.delete("km_range");
|
||||
|
||||
window.history.pushState(null, '', `/ads?${newSearchParams.toString()}`);
|
||||
|
||||
};
|
||||
|
||||
return (
|
||||
<ul>
|
||||
<li>
|
||||
<div className="flex items-center rounded">
|
||||
{countries?.isLoading ? (
|
||||
<Loader2 className="size-[14px] animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<button
|
||||
className="text-sm p-1 hover:bg-muted rounded-sm"
|
||||
onClick={handleToggleExpand}
|
||||
>
|
||||
{countries.expanded ? <Minus size={14} /> : <Plus size={14} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleAllLocationsClick}
|
||||
className={cn(
|
||||
"flex-1 ltr:text-left rtl:text-right py-1 px-2 rounded-sm",
|
||||
isAllSelected && "border bg-muted"
|
||||
)}
|
||||
>
|
||||
{t("allCountries")}
|
||||
</button>
|
||||
</div>
|
||||
{countries.expanded && countries.data.length > 0 && (
|
||||
<ul className="ltr:ml-3 rtl:mr-3 ltr:border-l rtl:border-r ltr:pl-2 rtl:pr-2 space-y-1">
|
||||
{countries.data.map((country) => (
|
||||
<CountryNode key={country.id + langCode} country={country} />
|
||||
))}
|
||||
|
||||
{countries.hasMore && (
|
||||
<button
|
||||
onClick={() => fetchCountries(countries.currentPage + 1)}
|
||||
className="text-primary text-center text-sm py-1 px-2"
|
||||
disabled={countries.isLoadMore}
|
||||
>
|
||||
{countries.isLoadMore ? t("loading") : t("loadMore")}
|
||||
</button>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export default LocationTree;
|
||||
78
components/Filter/RangeFilter.jsx
Normal file
78
components/Filter/RangeFilter.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useState } from "react";
|
||||
import { Slider } from "../ui/slider";
|
||||
import { useSelector } from "react-redux";
|
||||
import { getMaxRange, getMinRange } from "@/redux/reducer/settingSlice";
|
||||
import { t } from "@/utils";
|
||||
import { Button } from "../ui/button";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { getIsRtl } from "@/redux/reducer/languageSlice";
|
||||
|
||||
const RangeFilter = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const isRTL = useSelector(getIsRtl);
|
||||
|
||||
const kmRange = searchParams.get("km_range");
|
||||
const areaId = searchParams.get("areaId");
|
||||
const lat = searchParams.get("lat");
|
||||
const lng = searchParams.get("lng");
|
||||
|
||||
const min = useSelector(getMinRange);
|
||||
const max = useSelector(getMaxRange);
|
||||
|
||||
const [value, setValue] = useState([kmRange || min]);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleRangeApply = () => {
|
||||
if (!areaId) {
|
||||
setError(t("pleaseSelectArea"));
|
||||
return;
|
||||
}
|
||||
|
||||
const isInvalidCoord = (value) =>
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
value === "" ||
|
||||
isNaN(Number(value));
|
||||
|
||||
if (isInvalidCoord(lat) || isInvalidCoord(lng)) {
|
||||
setError(t("InvalidLatOrLng"));
|
||||
return;
|
||||
}
|
||||
const newSearchParams = new URLSearchParams(searchParams);
|
||||
newSearchParams.set("km_range", value);
|
||||
window.history.pushState(null, '', `/ads?${newSearchParams.toString()}`);
|
||||
setError("");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2 justify-between">
|
||||
<span>{t("rangeLabel")}</span>
|
||||
<span>{value} KM</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Slider
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
max={max}
|
||||
min={min}
|
||||
step={1}
|
||||
dir={isRTL ? "rtl" : "ltr"}
|
||||
/>
|
||||
{error && <span className="text-sm text-destructive">{error}</span>}
|
||||
</div>
|
||||
<Button
|
||||
className="hover:bg-primary hover:text-white w-full"
|
||||
variant="outline"
|
||||
onClick={handleRangeApply}
|
||||
disabled={value[0] <= 0}
|
||||
>
|
||||
{t("apply")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RangeFilter;
|
||||
162
components/Filter/StateNode.jsx
Normal file
162
components/Filter/StateNode.jsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { Loader2, Minus, Plus } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import CityNode from "./CityNode";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getCitiesApi } from "@/utils/api";
|
||||
import { t } from "@/utils";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { setSelectedLocation } from "@/redux/reducer/globalStateSlice";
|
||||
|
||||
const StateNode = ({ state, country }) => {
|
||||
const dispatch = useDispatch();
|
||||
const searchParams = useSearchParams();
|
||||
const [cities, setCities] = useState({
|
||||
data: [],
|
||||
currentPage: 1,
|
||||
hasMore: false,
|
||||
isLoading: false,
|
||||
isLoadMore: false,
|
||||
expanded: false,
|
||||
});
|
||||
|
||||
const selectedCity = searchParams.get("city") || "";
|
||||
const selectedArea = searchParams.get("area") || "";
|
||||
const selectedState = searchParams.get("state") || "";
|
||||
const lat = searchParams.get("lat") || "";
|
||||
const lng = searchParams.get("lng") || "";
|
||||
|
||||
const isSelected = useMemo(() => {
|
||||
return (
|
||||
state?.latitude === lat &&
|
||||
state?.longitude === lng &&
|
||||
!selectedCity &&
|
||||
!selectedArea
|
||||
);
|
||||
}, [lat, lng]);
|
||||
|
||||
const shouldExpand = selectedState === state?.name && selectedCity;
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldExpand && !cities.expanded) {
|
||||
fetchCities();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelected) {
|
||||
dispatch(setSelectedLocation(state));
|
||||
}
|
||||
}, [isSelected, state]);
|
||||
|
||||
const fetchCities = async (page = 1) => {
|
||||
try {
|
||||
page > 1
|
||||
? setCities((prev) => ({ ...prev, isLoadMore: true }))
|
||||
: setCities((prev) => ({ ...prev, isLoading: true }));
|
||||
|
||||
const response = await getCitiesApi.getCities({
|
||||
state_id: state.id,
|
||||
page,
|
||||
});
|
||||
const newData = response?.data?.data?.data ?? [];
|
||||
const currentPage = response?.data?.data?.current_page;
|
||||
const lastPage = response?.data?.data?.last_page;
|
||||
|
||||
setCities((prev) => ({
|
||||
...prev,
|
||||
data: page > 1 ? [...prev.data, ...newData] : newData,
|
||||
currentPage,
|
||||
hasMore: lastPage > currentPage,
|
||||
expanded: true,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setCities((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
isLoadMore: false,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleExpand = async () => {
|
||||
if (!cities.expanded && cities.data.length === 0) {
|
||||
await fetchCities();
|
||||
} else {
|
||||
setCities((prev) => ({ ...prev, expanded: !prev.expanded }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
const newSearchParams = new URLSearchParams(searchParams);
|
||||
newSearchParams.set("country", country?.name);
|
||||
newSearchParams.set("state", state?.name);
|
||||
newSearchParams.set("lat", state.latitude);
|
||||
newSearchParams.set("lng", state.longitude);
|
||||
newSearchParams.delete("city");
|
||||
newSearchParams.delete("area");
|
||||
newSearchParams.delete("areaId");
|
||||
newSearchParams.delete("km_range");
|
||||
window.history.pushState(null, '', `/ads?${newSearchParams.toString()}`);
|
||||
};
|
||||
|
||||
const loadMore = async () => {
|
||||
await fetchCities(cities.currentPage + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<li>
|
||||
<div className="flex items-center rounded">
|
||||
{cities?.isLoading ? (
|
||||
<Loader2 className="size-[14px] animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
state.cities_count > 0 && (
|
||||
<button
|
||||
className="text-sm p-1 hover:bg-muted rounded-sm"
|
||||
onClick={handleToggleExpand}
|
||||
>
|
||||
{cities.expanded ? <Minus size={14} /> : <Plus size={14} />}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"flex-1 ltr:text-left rtl:text-right py-1 px-2 rounded-sm",
|
||||
isSelected && "border bg-muted"
|
||||
)}
|
||||
>
|
||||
{state?.translated_name || state?.name}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{cities.expanded && (
|
||||
<ul className="ltr:ml-3 rtl:mr-3 ltr:border-l rtl:border-r ltr:pl-2 rtl:pr-2 space-y-1">
|
||||
{cities.data.map((city) => (
|
||||
<CityNode
|
||||
key={city.id}
|
||||
city={city}
|
||||
country={country}
|
||||
state={state}
|
||||
/>
|
||||
))}
|
||||
|
||||
{cities.hasMore && (
|
||||
<button
|
||||
onClick={loadMore}
|
||||
className="text-primary text-center text-sm py-1 px-2"
|
||||
disabled={cities.isLoadMore}
|
||||
>
|
||||
{cities.isLoadMore ? t("loading") : t("loadMore")}
|
||||
</button>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default StateNode;
|
||||
Reference in New Issue
Block a user