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,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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;