328 lines
11 KiB
TypeScript
328 lines
11 KiB
TypeScript
/* eslint-disable react-hooks/exhaustive-deps */
|
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
|
import { api } from "../api/ky";
|
|
import { IUnit } from "../types/IUnit";
|
|
import UnitCard from "../components/UnitCard";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import SearchFilters from "../components/SearchFilters";
|
|
import { useSearchParams } from "react-router";
|
|
import Button from "../components/ui/Button";
|
|
import FiltersIcon from "../components/icons/FiltersIcon";
|
|
import RestartIcon from "../components/icons/RestartIcon";
|
|
import clsx from "clsx";
|
|
import { AnimatePresence, motion } from "motion/react";
|
|
import Select from "../components/ui/Select";
|
|
import Skeleton from "react-loading-skeleton";
|
|
import { SORT_OPTIONS } from "../data/sortOptions";
|
|
|
|
const STEP = 12;
|
|
|
|
function SearchPage() {
|
|
const [searchParams] = useSearchParams();
|
|
const [project, setProject] = useState(
|
|
searchParams.get("project") || "Rove Home Marasi Drive"
|
|
);
|
|
const [selectedUnitTypes, setSelectedUnitTypes] = useState<string[]>([]);
|
|
// const unitTypes = searchParams.getAll("unitTypes");
|
|
// const view = searchParams.get("view");
|
|
|
|
const [filtersInModal, setFiltersInModal] = useState(false);
|
|
|
|
const [cost, setCost] = useState<[number, number]>([-1, -1]);
|
|
const [floor, setFloor] = useState<[number, number]>([-1, -1]);
|
|
const [area, setArea] = useState<[number, number]>([-1, -1]);
|
|
const [view, setView] = useState("Any view");
|
|
|
|
const [sort, setSort] = useState<keyof typeof SORT_OPTIONS>(
|
|
"Sort by ascending price"
|
|
);
|
|
|
|
const filtersRef = useRef<HTMLDivElement>(null);
|
|
const observerRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => window.scrollTo({ top: 0, behavior: "smooth" }), []);
|
|
|
|
const [showButtons, setShowButtons] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const observerElement = filtersRef.current;
|
|
const observer = new IntersectionObserver((entries) =>
|
|
setShowButtons(!entries[0].isIntersecting)
|
|
);
|
|
if (observerElement) observer.observe(observerElement);
|
|
|
|
return () => {
|
|
if (observerElement) observer.unobserve(observerElement);
|
|
};
|
|
}, []);
|
|
|
|
const [footerReached, setFooterReached] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const footer = document.querySelector("footer");
|
|
const observer = new IntersectionObserver((entries) =>
|
|
setFooterReached(entries[0].isIntersecting)
|
|
);
|
|
if (footer) observer.observe(footer);
|
|
|
|
return () => {
|
|
if (footer) observer.unobserve(footer);
|
|
};
|
|
}, []);
|
|
|
|
const [activeFiltersCount, setActiveFiltersCount] = useState(0);
|
|
|
|
useEffect(
|
|
() =>
|
|
setActiveFiltersCount(
|
|
+searchParams.has("view") +
|
|
+searchParams.has("unitTypes") +
|
|
+searchParams.has("cost") +
|
|
+searchParams.has("floor") +
|
|
+searchParams.has("area")
|
|
),
|
|
[searchParams]
|
|
);
|
|
|
|
const { data: allUnitTypes } = useQuery({
|
|
queryKey: ["filters", "unitTypes", project],
|
|
queryFn: () =>
|
|
api.get(`units/filters/unitTypes?project=${project}`).json<string[]>(),
|
|
});
|
|
|
|
const { data: allViews } = useQuery({
|
|
queryKey: ["filters", "views", project],
|
|
queryFn: () =>
|
|
api.get(`units/filters/views?project=${project}`).json<string[]>(),
|
|
});
|
|
|
|
const { data: allFloors } = useQuery({
|
|
queryKey: ["filters", "floors", project],
|
|
queryFn: () =>
|
|
api.get(`units/filters/floor?project=${project}`).json<{
|
|
min: number;
|
|
max: number;
|
|
}>(),
|
|
});
|
|
|
|
const { data: allCost } = useQuery({
|
|
queryKey: ["filters", "cost", project],
|
|
queryFn: () =>
|
|
api.get(`units/filters/cost?project=${project}`).json<{
|
|
min: number;
|
|
max: number;
|
|
}>(),
|
|
});
|
|
|
|
const { data: allArea } = useQuery({
|
|
queryKey: ["filters", "area", project],
|
|
queryFn: () =>
|
|
api.get(`units/filters/area?project=${project}`).json<{
|
|
min: number;
|
|
max: number;
|
|
}>(),
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (allFloors) setFloor([allFloors.min, allFloors.max]);
|
|
}, [allFloors]);
|
|
|
|
useEffect(() => {
|
|
if (allCost) setCost([allCost.min, allCost.max]);
|
|
}, [allCost]);
|
|
|
|
useEffect(() => {
|
|
if (allArea) setArea([allArea.min, allArea.max]);
|
|
}, [allArea]);
|
|
|
|
const { data, fetchNextPage, isLoading, hasNextPage, isFetchingNextPage } =
|
|
useInfiniteQuery({
|
|
initialPageParam: 0,
|
|
queryKey: [
|
|
"units",
|
|
project,
|
|
selectedUnitTypes,
|
|
view,
|
|
cost,
|
|
floor,
|
|
area,
|
|
sort,
|
|
],
|
|
enabled:
|
|
!!project &&
|
|
cost[0] >= 0 &&
|
|
cost[1] >= 0 &&
|
|
floor[0] >= 0 &&
|
|
floor[1] >= 0 &&
|
|
area[0] >= 0 &&
|
|
area[1] >= 0,
|
|
queryFn: async ({ pageParam = 0 }) =>
|
|
await api
|
|
.get(
|
|
`units?offset=${pageParam}&limit=${STEP}${
|
|
project ? `&project=${project}` : ""
|
|
}${selectedUnitTypes
|
|
.map((unitType) => `&unitTypes=${unitType}`)
|
|
.join("")}${view !== "Any view" ? `&view=${view}` : ""}${
|
|
cost.length > 0
|
|
? `&cost=${Math.floor(cost[0])},${Math.ceil(cost[1])}`
|
|
: ""
|
|
}${
|
|
floor.length > 0
|
|
? `&floor=${Math.floor(floor[0])},${Math.ceil(floor[1])}`
|
|
: ""
|
|
}${
|
|
area.length > 0
|
|
? `&area=${Math.floor(area[0])},${Math.ceil(area[1])}`
|
|
: ""
|
|
}${sort ? `&order=${SORT_OPTIONS[sort].split(" ").join()}` : ""}`
|
|
)
|
|
.json<IUnit[]>(),
|
|
getNextPageParam: (lastPage, _, lastPageIndex) =>
|
|
lastPage.length < STEP ? undefined : lastPageIndex + STEP,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!hasNextPage || isFetchingNextPage) return;
|
|
const observerElement = observerRef.current;
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
if (entries[0].isIntersecting) fetchNextPage();
|
|
},
|
|
{ rootMargin: "-200px" }
|
|
);
|
|
if (observerElement) observer.observe(observerElement);
|
|
|
|
return () => {
|
|
if (observerElement) observer.unobserve(observerElement);
|
|
};
|
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
|
|
|
return (
|
|
<>
|
|
<SearchFilters
|
|
allUnitTypes={allUnitTypes || []}
|
|
allCost={allCost || { min: 0, max: 0 }}
|
|
allFloors={allFloors || { min: 0, max: 0 }}
|
|
allArea={allArea || { min: 0, max: 0 }}
|
|
allViews={allViews || []}
|
|
project={project}
|
|
setProject={setProject}
|
|
selectedUnitTypes={selectedUnitTypes}
|
|
setSelectedUnitTypes={setSelectedUnitTypes}
|
|
view={view}
|
|
setView={setView}
|
|
ref={filtersRef}
|
|
inModal={filtersInModal}
|
|
setInModal={setFiltersInModal}
|
|
{...{ area, cost, floor, setArea, setCost, setFloor }}
|
|
/>
|
|
<div
|
|
className="2xl:p-[2.222vw] p-4 min-h-dvh 2xl:space-y-[1.111vw] space-y-4"
|
|
style={
|
|
filtersInModal ? { marginTop: filtersRef.current?.clientHeight } : {}
|
|
}
|
|
>
|
|
<Select
|
|
options={Object.keys(SORT_OPTIONS)}
|
|
defaultOption={sort}
|
|
onSelect={(opt) => setSort(opt as keyof typeof SORT_OPTIONS)}
|
|
className="2xl:max-w-[22.778vw] md:max-2xl:max-w-[45.833vw]"
|
|
/>
|
|
<hr className="2xl:h-[0.069vw] border-[#E2E2DC]" />
|
|
<AnimatePresence mode="wait">
|
|
{isLoading ? (
|
|
<div className="2xl:grid-cols-4 md:max-2xl:grid-cols-2 grid 2xl:gap-[1.111vw] gap-4">
|
|
{Array.from({ length: STEP }).map((_, i) => (
|
|
<Skeleton
|
|
key={i}
|
|
borderRadius={16}
|
|
className="2xl:aspect-[332/396] md:max-2xl:aspect-[352/396] aspect-[328/396]"
|
|
/>
|
|
))}
|
|
</div>
|
|
) : data?.pages[0].length === 0 ? (
|
|
<div className="2xl:aspect-[1376/396] md:max-2xl:py-20 max-md:aspect-[328/240] flex justify-center items-center">
|
|
<p className="text-h3 font-medium text-[#00BED7] text-center">
|
|
No apartments found with the given parameters
|
|
</p>
|
|
</div>
|
|
) : (
|
|
project &&
|
|
selectedUnitTypes &&
|
|
cost &&
|
|
area &&
|
|
floor &&
|
|
sort && (
|
|
<motion.div
|
|
key={
|
|
project +
|
|
selectedUnitTypes +
|
|
view +
|
|
cost +
|
|
area +
|
|
floor +
|
|
sort
|
|
}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="2xl:grid-cols-4 md:max-2xl:grid-cols-2 grid 2xl:gap-[1.111vw] gap-4"
|
|
>
|
|
{data?.pages.map((page) =>
|
|
page.map((unit) => <UnitCard key={unit.id} unit={unit} />)
|
|
)}
|
|
{isFetchingNextPage &&
|
|
Array.from({ length: STEP }).map((_, i) => (
|
|
<Skeleton
|
|
key={"fetching-" + i}
|
|
borderRadius={16}
|
|
className="2xl:aspect-[332/396] md:max-2xl:aspect-[352/396] aspect-[328/396]"
|
|
/>
|
|
))}
|
|
</motion.div>
|
|
)
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
{showButtons && (
|
|
<div
|
|
className={clsx(
|
|
"fixed left-1/2 -translate-x-1/2 flex justify-center 2xl:gap-[0.278vw] gap-2 transition-all z-2",
|
|
footerReached && !hasNextPage
|
|
? "top-[calc(100dvh-17.222vw)] translate-y-0"
|
|
: "top-[calc(100dvh-2.222vw)] -translate-y-full"
|
|
)}
|
|
>
|
|
<Button onClick={() => setFiltersInModal(true)} className="relative">
|
|
<span className="2xl:w-[1.111vw] 2xl:h-[1.111vw] w-4 h-4 text-white">
|
|
<FiltersIcon />
|
|
</span>
|
|
<span className="text-caption-m">Filters</span>
|
|
{!!activeFiltersCount && (
|
|
<div className="absolute 2xl:top-[0.139vw] 2xl:right-[0.139vw] top-0.5 right-0.5 rounded-full w-4 h-4 text-caption-s bg-white text-[#00BED7] flex justify-center items-center font-mono ring ring-[#E2E2DC]">
|
|
{activeFiltersCount}
|
|
</div>
|
|
)}
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
className="2xl:!outline-[0.069vw] !outline !outline-[#E2E2DC]"
|
|
onClick={() => {
|
|
window.location.href = "/search";
|
|
}}
|
|
>
|
|
<span className="2xl:w-[1.111vw] 2xl:h-[1.111vw] w-4 h-4 text-[#0D1922]/70">
|
|
<RestartIcon />
|
|
</span>
|
|
<span className="text-caption-m">Reset</span>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
<div ref={observerRef} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default SearchPage;
|