search pages with filters in process
This commit is contained in:
@@ -5,12 +5,12 @@
|
||||
"name": "irth-new",
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@tanstack/react-query": "^5.72.1",
|
||||
"@tanstack/react-query": "^5.74.4",
|
||||
"@tweenjs/tween.js": "^25.0.0",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"clsx": "^2.1.1",
|
||||
"gsap": "^3.12.7",
|
||||
"ky": "^1.8.0",
|
||||
"ky": "^1.8.1",
|
||||
"motion": "^12.6.3",
|
||||
"openmeteo": "^1.2.0",
|
||||
"react": "^19.0.0",
|
||||
@@ -216,9 +216,9 @@
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.3", "", { "dependencies": { "@tailwindcss/node": "4.1.3", "@tailwindcss/oxide": "4.1.3", "tailwindcss": "4.1.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6" } }, "sha512-lUI/QaDxLtlV52Lho6pu07CG9pSnRYLOPmKGIQjyHdTBagemc6HmgZxyjGAQ/5HMPrNeWBfTVIpQl0/jLXvWHQ=="],
|
||||
|
||||
"@tanstack/query-core": ["@tanstack/query-core@5.72.1", "", {}, "sha512-nOu0EEkZuJ0BZnYgeaEfo44+psq1jBO7/zp3KudixD4dvgOVerrhAhDEKsWx2N7MxB59mjO4r0ddP/VqWGPK+Q=="],
|
||||
"@tanstack/query-core": ["@tanstack/query-core@5.74.4", "", {}, "sha512-YuG0A0+3i9b2Gfo9fkmNnkUWh5+5cFhWBN0pJAHkHilTx6A0nv8kepkk4T4GRt4e5ahbtFj2eTtkiPcVU1xO4A=="],
|
||||
|
||||
"@tanstack/react-query": ["@tanstack/react-query@5.72.1", "", { "dependencies": { "@tanstack/query-core": "5.72.1" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-4UEMyRx54xj144D2nDvDIMiXSG5BrqyCJrmyNoGbymNS+VWODcBDFrmRk9p2fe12UGZ4JtKPTNuW2Jg0aisUgQ=="],
|
||||
"@tanstack/react-query": ["@tanstack/react-query@5.74.4", "", { "dependencies": { "@tanstack/query-core": "5.74.4" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-mAbxw60d4ffQ4qmRYfkO1xzRBPUEf/72Dgo3qqea0J66nIKuDTLEqQt0ku++SDFlMGMnB6uKDnEG1xD/TDse4Q=="],
|
||||
|
||||
"@tweenjs/tween.js": ["@tweenjs/tween.js@25.0.0", "", {}, "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A=="],
|
||||
|
||||
@@ -390,7 +390,7 @@
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"ky": ["ky@1.8.0", "", {}, "sha512-DoKGmG27nT8t/1F9gV8vNzggJ3mLAyD49J8tTMWHeZvS8qLc7GlyTieicYtFzvDznMe/q2u38peOjkWc5/pjvw=="],
|
||||
"ky": ["ky@1.8.1", "", {}, "sha512-7Bp3TpsE+L+TARSnnDpk3xg8Idi8RwSLdj6CMbNWoOARIrGrbuLGusV0dYwbZOm4bB3jHNxSw8Wk/ByDqJEnDw=="],
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
|
||||
+2
-2
@@ -11,12 +11,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@tanstack/react-query": "^5.72.1",
|
||||
"@tanstack/react-query": "^5.74.4",
|
||||
"@tweenjs/tween.js": "^25.0.0",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"clsx": "^2.1.1",
|
||||
"gsap": "^3.12.7",
|
||||
"ky": "^1.8.0",
|
||||
"ky": "^1.8.1",
|
||||
"motion": "^12.6.3",
|
||||
"openmeteo": "^1.2.0",
|
||||
"react": "^19.0.0",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,5 @@
|
||||
import ky from "ky";
|
||||
|
||||
export const api = ky.create({
|
||||
prefixUrl: import.meta.env.VITE_API_URL,
|
||||
});
|
||||
@@ -10,7 +10,7 @@ function Compass({ imgStyle }: CompassProps) {
|
||||
<img
|
||||
src="/images/map/compass.png"
|
||||
className={clsx(
|
||||
"2xl:w-[7.222vw] w-26 pointer-events-none absolute 2xl:left-[1.667vw] md:max-2xl:bottom-4 left-4 2xl:bottom-[1.667vw] max-md:hidden z-10"
|
||||
"2xl:w-[7.222vw] w-26 pointer-events-none absolute 2xl:left-[2.222vw] md:max-2xl:bottom-4 left-4 2xl:bottom-[2.222vw] max-md:hidden"
|
||||
)}
|
||||
style={imgStyle}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { IUnit } from "../types/IUnit";
|
||||
import HeartIcon from "./icons/HeartIcon";
|
||||
import Button from "./ui/Button";
|
||||
|
||||
function FlatCard({
|
||||
project,
|
||||
unitNo,
|
||||
floor,
|
||||
unitType,
|
||||
squareFt,
|
||||
salesPrice,
|
||||
}: IUnit) {
|
||||
return (
|
||||
<div className="2xl:p-[1.111vw] p-4 2xl:rounded-[1.111vw] rounded-2xl flex flex-col 2xl:gap-[1.111vw] gap-4 bg-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="2xl:space-y-[0.278vw] space-y-1">
|
||||
<p>{project}</p>
|
||||
<div className="flex items-center 2xl:gap-[0.556vw] gap-2">
|
||||
<p className="text-caption-m">
|
||||
{(unitNo.split("-")[0] === "W" ? "West" : "East") + " Wing"}
|
||||
</p>
|
||||
<div className="2xl:w-[0.278vw] w-1 aspect-square bg-[#E2E2DC] rounded-full" />
|
||||
<p className="text-caption-m">Floor {floor}</p>
|
||||
<div className="2xl:w-[0.278vw] w-1 aspect-square bg-[#E2E2DC] rounded-full" />
|
||||
<p className="text-caption-m">{unitNo}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onlyIcon variant="secondary">
|
||||
<span className="2xl:w-[1.389vw] w-5 aspect-square text-[#0D1922]/70">
|
||||
<HeartIcon />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="2xl:space-y-[0.278vw] space-y-1">
|
||||
<p className="text-s">
|
||||
{unitType},{" "}
|
||||
{squareFt.toLocaleString(undefined, { maximumFractionDigits: 2 })}{" "}
|
||||
Sqft
|
||||
</p>
|
||||
<p className="text-[#00BED7]">
|
||||
AED {Intl.NumberFormat("en").format(salesPrice)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FlatCard;
|
||||
@@ -7,7 +7,7 @@ import TwitterIcon from "./icons/TwitterIcon";
|
||||
|
||||
function Footer() {
|
||||
return (
|
||||
<footer className="lg:px-[2.222vw] lg:pb-[2.222vw] lg:pt-[2.778vw] md:max-lg:p-6 px-4 py-6 grid md:grid-cols-6 grid-cols-3 lg:grid-rows-2 lg:gap-x-[1.667vw] lg:gap-y-[1.111vw] max-lg:gap-y-6 lg:rounded-t-[1.667vw] rounded-t-3xl outline outline-[#E2E2DC]">
|
||||
<footer className="lg:px-[2.222vw] lg:pb-[2.222vw] lg:pt-[2.778vw] md:max-lg:p-6 px-4 py-6 grid md:grid-cols-6 grid-cols-3 lg:grid-rows-2 lg:gap-x-[1.667vw] lg:gap-y-[1.111vw] max-lg:gap-y-6 lg:rounded-t-[1.667vw] rounded-t-3xl outline outline-[#E2E2DC] bg-white">
|
||||
<img
|
||||
src="/images/logo.svg"
|
||||
className="lg:w-[5.972vw] w-[86px] cursor-pointer"
|
||||
|
||||
@@ -34,7 +34,7 @@ function FullScreenButton({
|
||||
onlyIcon
|
||||
size="small"
|
||||
variant="secondary"
|
||||
className="absolute 2xl:top-[1.667vw] 2xl:right-[1.667vw] top-4 right-4"
|
||||
className="absolute 2xl:top-[2.222vw] 2xl:right-[2.222vw] top-4 right-4"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span className="2xl:w-[1.389vw] 2xl:h-[1.389vw] w-5 h-5">
|
||||
|
||||
@@ -32,7 +32,7 @@ function Header() {
|
||||
</div>
|
||||
</div>
|
||||
<NavBar />
|
||||
<div className="flex-1 flex justify-end">
|
||||
<div className="flex justify-end flex-1">
|
||||
<ProfileBar />
|
||||
</div>
|
||||
</header>
|
||||
@@ -84,7 +84,7 @@ function NavItem({ href, title }: { href: string; title: string }) {
|
||||
|
||||
function ProfileBar() {
|
||||
return (
|
||||
<Button className="!bg-[#F3F3F2] 2xl:mr-[2.222vw] mr-4" variant="secondary">
|
||||
<Button className="!bg-[#F3F3F2] 2xl:mr-[1.667vw] mr-4" variant="secondary">
|
||||
Login
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -561,7 +561,7 @@ function Map({ maxZoom = 1 }: MapProps) {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative h-full overflow-hidden select-none touch-none"
|
||||
className="touch-none relative h-full overflow-hidden select-none"
|
||||
style={{ cursor: isDragging ? "grabbing" : "grab" }}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
@@ -692,7 +692,7 @@ function Map({ maxZoom = 1 }: MapProps) {
|
||||
onFullScreenChange={setIsFullScreen}
|
||||
onClick={handleFullScreenClick}
|
||||
/>
|
||||
<div className="absolute 2xl:right-[1.667vw] 2xl:bottom-[1.667vw] right-4 bottom-4 flex 2xl:gap-[0.556vw] gap-2">
|
||||
<div className="absolute 2xl:right-[2.222vw] 2xl:bottom-[2.222vw] right-4 bottom-4 flex 2xl:gap-[0.556vw] gap-2">
|
||||
<DisclaimerButton />
|
||||
<PrivacyPolicyButton />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import clsx from "clsx";
|
||||
import { useSearchParams } from "react-router";
|
||||
|
||||
function ProjectFilter({ title, img }: { title: string; img: string }) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
setSearchParams((prev) => {
|
||||
prev.set("project", title);
|
||||
return prev;
|
||||
});
|
||||
}}
|
||||
className={clsx(
|
||||
"2xl:rounded-[2.778vw] rounded-[40px] 2xl:p-[0.278vw] p-1 flex items-center 2xl:gap-[0.556vw] gap-2 text-s 2xl:outline-[0.069vw] outline transition-colors duration-300 cursor-pointer",
|
||||
searchParams.get("project") === title
|
||||
? "outline-[#00BED7]"
|
||||
: "outline-[#E2E2DC]"
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={img}
|
||||
alt=""
|
||||
className="object-cover 2xl:w-[2.778vw] w-10 aspect-square rounded-full"
|
||||
/>
|
||||
<p className="2xl:mr-[1.111vw] mr-6">{title}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProjectFilter;
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import RestartIcon from "./icons/RestartIcon";
|
||||
import Button from "./ui/Button";
|
||||
import MultiRangeSlider from "./ui/MultiRangeSlider";
|
||||
import { api } from "../api/ky";
|
||||
import { RefObject, useEffect, useState } from "react";
|
||||
import ProjectFilter from "./ProjectFilter";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
import UnitTypesFilter from "./UnitTypesFilter";
|
||||
import { projects } from "../data/projects";
|
||||
import { useDebounce } from "../hooks/useDebounce";
|
||||
|
||||
interface Filters {
|
||||
unitTypes: string[];
|
||||
views: string[];
|
||||
area: [number, number];
|
||||
cost: [number, number];
|
||||
floor: [number, number];
|
||||
}
|
||||
|
||||
function SearchFilters({
|
||||
inModal = false,
|
||||
ref,
|
||||
}: {
|
||||
inModal?: boolean;
|
||||
ref: RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
const [project, setProject] = useState<string>();
|
||||
const [unitTypes, setUnitTypes] = useState<string[]>([]);
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: filters } = useQuery({
|
||||
queryKey: ["filters", project],
|
||||
queryFn: () =>
|
||||
api
|
||||
.get(`units/filters?${project ? `project=${project}` : ""}`)
|
||||
.json<Filters>(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const projectValue = searchParams.get("project");
|
||||
const unitTypesValue = searchParams.getAll("unitTypes");
|
||||
if (projectValue) setProject(projectValue);
|
||||
if (unitTypesValue) setUnitTypes(unitTypesValue);
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filters) {
|
||||
setCurrentMinCost(filters.cost[0]);
|
||||
setCurrentMaxCost(filters.cost[1]);
|
||||
setCurrentMinArea(filters.area[0]);
|
||||
setCurrentMaxArea(filters.area[1]);
|
||||
setCurrentMinFloor(filters.floor[0]);
|
||||
setCurrentMaxFloor(filters.floor[1]);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
const [currentMinCost, setCurrentMinCost] = useState<number>();
|
||||
const [currentMaxCost, setCurrentMaxCost] = useState<number>();
|
||||
|
||||
const [currentMinArea, setCurrentMinArea] = useState<number>();
|
||||
const [currentMaxArea, setCurrentMaxArea] = useState<number>();
|
||||
|
||||
const [currentMinFloor, setCurrentMinFloor] = useState<number>();
|
||||
const [currentMaxFloor, setCurrentMaxFloor] = useState<number>();
|
||||
|
||||
function resetFilters() {
|
||||
setProject(undefined);
|
||||
setUnitTypes([]);
|
||||
if (filters) {
|
||||
setCurrentMinCost(filters.cost[0]);
|
||||
setCurrentMaxCost(filters.cost[1]);
|
||||
setCurrentMinArea(filters.area[0]);
|
||||
setCurrentMaxArea(filters.area[1]);
|
||||
setCurrentMinFloor(filters.floor[0]);
|
||||
setCurrentMaxFloor(filters.floor[1]);
|
||||
}
|
||||
navigate("/search");
|
||||
}
|
||||
|
||||
const debouncedMinCost = useDebounce(currentMinCost, 500);
|
||||
const debouncedMaxCost = useDebounce(currentMaxCost, 500);
|
||||
|
||||
const debouncedMinArea = useDebounce(currentMinArea, 500);
|
||||
const debouncedMaxArea = useDebounce(currentMaxArea, 500);
|
||||
|
||||
const debouncedMinFloor = useDebounce(currentMinFloor, 500);
|
||||
const debouncedMaxFloor = useDebounce(currentMaxFloor, 500);
|
||||
|
||||
const { data: count } = useQuery({
|
||||
queryKey: [
|
||||
"units",
|
||||
"count",
|
||||
project,
|
||||
unitTypes,
|
||||
debouncedMinCost,
|
||||
debouncedMaxCost,
|
||||
debouncedMinArea,
|
||||
debouncedMaxArea,
|
||||
debouncedMinFloor,
|
||||
debouncedMaxFloor,
|
||||
],
|
||||
queryFn: () =>
|
||||
api
|
||||
.get(
|
||||
`units/count?${project ? `project=${project}` : ""}${unitTypes
|
||||
.map((unitType) => `&unitTypes=${unitType}`)
|
||||
.join("")}${
|
||||
debouncedMinCost && debouncedMaxCost
|
||||
? `&cost=${Math.round(debouncedMinCost)},${Math.round(
|
||||
debouncedMaxCost
|
||||
)}`
|
||||
: ""
|
||||
}${
|
||||
debouncedMinArea && debouncedMaxArea
|
||||
? `&area=${Math.round(debouncedMinArea)},${Math.round(
|
||||
debouncedMaxArea
|
||||
)}`
|
||||
: ""
|
||||
}${
|
||||
debouncedMinFloor && debouncedMaxFloor
|
||||
? `&floor=${Math.round(debouncedMinFloor)},${Math.round(
|
||||
debouncedMaxFloor
|
||||
)}`
|
||||
: ""
|
||||
}`
|
||||
)
|
||||
.json<number>(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedMinCost && debouncedMaxCost)
|
||||
setSearchParams((prev) => {
|
||||
prev.set(
|
||||
"cost",
|
||||
`${Math.round(debouncedMinCost)},${Math.round(debouncedMaxCost)}`
|
||||
);
|
||||
return prev;
|
||||
});
|
||||
|
||||
if (debouncedMinArea && debouncedMaxArea)
|
||||
setSearchParams((prev) => {
|
||||
prev.set(
|
||||
"area",
|
||||
`${Math.round(debouncedMinArea)},${Math.round(debouncedMaxArea)}`
|
||||
);
|
||||
return prev;
|
||||
});
|
||||
|
||||
if (debouncedMinFloor && debouncedMaxFloor)
|
||||
setSearchParams((prev) => {
|
||||
prev.set(
|
||||
"floor",
|
||||
`${Math.round(debouncedMinFloor)},${Math.round(debouncedMaxFloor)}`
|
||||
);
|
||||
return prev;
|
||||
});
|
||||
}, [
|
||||
debouncedMinCost,
|
||||
debouncedMaxCost,
|
||||
debouncedMinArea,
|
||||
debouncedMaxArea,
|
||||
debouncedMinFloor,
|
||||
debouncedMaxFloor,
|
||||
setSearchParams,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="2xl:p-[2.222vw] md:max-2xl:p-6 p-4 2xl:-mx-[2.222vw] md:max-2xl:-mx-6 -mx-4 bg-white 2xl:rounded-b-[1.667vw] rounded-b-3xl 2xl:space-y-[2.222vw] md:max-2xl:space-y-8 space-y-4"
|
||||
>
|
||||
<div className="2xl:space-y-[1.111vw] space-y-4">
|
||||
<p className="2xl:text-[2.222vw] md:max-2xl:text-[32px] text-2xl font-semibold leading-[135%]">
|
||||
Search
|
||||
</p>
|
||||
<div className="flex 2xl:gap-[0.556vw] gap-2">
|
||||
{projects.map((project) => (
|
||||
<ProjectFilter
|
||||
key={project.title}
|
||||
title={project.title}
|
||||
img={project.img}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<hr className="2xl:h-[0.069vw] h-px border-[#E2E2DC]" />
|
||||
{filters && (
|
||||
<>
|
||||
<div className="2xl:space-y-[0.556vw] space-y-2">
|
||||
<p>Apartment type</p>
|
||||
<div className="flex 2xl:gap-[0.556vw] gap-2">
|
||||
{filters.unitTypes.map((unitType) => (
|
||||
<UnitTypesFilter key={unitType} title={unitType} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid 2xl:grid-cols-4 md:max-2xl:grid-cols-2 md:max-2xl:grid-rows-2 2xl:gap-[1.111vw] gap-4">
|
||||
<MultiRangeSlider
|
||||
min={filters?.cost[0]}
|
||||
max={filters?.cost[1]}
|
||||
currentMin={currentMinCost!}
|
||||
currentMax={currentMaxCost!}
|
||||
step={1}
|
||||
onChangeMin={setCurrentMinCost}
|
||||
onChangeMax={setCurrentMaxCost}
|
||||
label="Cost, AED"
|
||||
/>
|
||||
<MultiRangeSlider
|
||||
min={filters?.floor[0]}
|
||||
max={filters?.floor[1]}
|
||||
currentMin={currentMinFloor!}
|
||||
currentMax={currentMaxFloor!}
|
||||
step={1}
|
||||
onChangeMin={setCurrentMinFloor}
|
||||
onChangeMax={setCurrentMaxFloor}
|
||||
label="Floor"
|
||||
/>
|
||||
<MultiRangeSlider
|
||||
min={filters?.area[0]}
|
||||
max={filters?.area[1]}
|
||||
currentMin={currentMinArea!}
|
||||
currentMax={currentMaxArea!}
|
||||
step={1}
|
||||
onChangeMin={setCurrentMinArea}
|
||||
onChangeMax={setCurrentMaxArea}
|
||||
label="Total Area, Sqft"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center 2xl:gap-[1.111vw] md:max-2xl:gap-4">
|
||||
{inModal ? (
|
||||
<Button>Show {count} apartments</Button>
|
||||
) : (
|
||||
<p className="text-[#00BED7] text-s">{count} Apartments found</p>
|
||||
)}
|
||||
<Button variant="secondary" onClick={resetFilters}>
|
||||
<span className="2xl:w-[1.389vw] 2xl:h-[1.389vw] w-5 h-5 text-[#0D1922]/70">
|
||||
<RestartIcon />
|
||||
</span>
|
||||
<p className="text-s">Reset filters</p>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SearchFilters;
|
||||
@@ -0,0 +1,29 @@
|
||||
import clsx from "clsx";
|
||||
import { useSearchParams } from "react-router";
|
||||
|
||||
function UnitTypesFilter({ title }: { title: string }) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
setSearchParams((prev) => {
|
||||
if (prev.getAll("unitTypes").includes(title))
|
||||
prev.delete("unitTypes", title);
|
||||
else prev.append("unitTypes", title);
|
||||
return prev;
|
||||
});
|
||||
}}
|
||||
className={clsx(
|
||||
"2xl:px-[1.389vw] 2xl:py-[0.833vw] px-5 py-3 2xl:rounded-[2.778vw] rounded-[40px] outline transition-colors duration-300 cursor-pointer",
|
||||
searchParams.getAll("unitTypes").includes(title)
|
||||
? "outline-[#00BED7]"
|
||||
: "outline-[#E2E2DC]"
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UnitTypesFilter;
|
||||
@@ -18,20 +18,20 @@ export default function WeatherWidget({
|
||||
const formattedTime = `${hours}:${minutes}`;
|
||||
|
||||
return (
|
||||
<div className="absolute left-[1.667vw] top-[1.667vw] rounded-2xl space-y-4 min-w-50 w-[8.333vw] p-4 font-medium text-white bg-black/40 pointer-events-none max-[1440px]:hidden backdrop-blur-2xl">
|
||||
<div className="absolute left-[2.222vw] top-[2.222vw] rounded-2xl space-y-4 min-w-50 w-[8.333vw] p-4 font-medium text-white bg-black/40 pointer-events-none max-[1440px]:hidden backdrop-blur-2xl">
|
||||
<div>
|
||||
<div className="flex justify-between">
|
||||
<p>{day}</p>
|
||||
<p>{formattedTime}</p>
|
||||
</div>
|
||||
<div className="flex justify-between opacity-60">
|
||||
<div className="opacity-60 flex justify-between">
|
||||
<p>
|
||||
{date.getDate()} {month}
|
||||
</p>
|
||||
<p>{dayPart}</p>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="border-white -mx-4" />
|
||||
<hr className="-mx-4 border-white" />
|
||||
<p className="text-[32px]">{temperature}°C</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
function FiltersIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M3.692 8.333A.68.68 0 0 0 3 9c0 .368.31.667.692.667h1.029c.294.901 1.169 1.555 2.202 1.555s1.908-.654 2.202-1.555h11.183A.68.68 0 0 0 21 9a.68.68 0 0 0-.692-.667H9.125c-.294-.901-1.169-1.555-2.202-1.555s-1.908.654-2.202 1.555zm0 5.778a.68.68 0 0 0-.692.667c0 .368.31.666.692.666h9.414C13.4 16.346 14.274 17 15.308 17c1.033 0 1.908-.654 2.202-1.556h2.798a.68.68 0 0 0 .692-.666.68.68 0 0 0-.692-.667H17.51c-.294-.901-1.169-1.555-2.202-1.555-1.034 0-1.908.654-2.202 1.555z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default FiltersIcon;
|
||||
@@ -0,0 +1,15 @@
|
||||
function HeartIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M15.683 5.965a3.404 3.404 0 0 0-4.75 0L10 6.881l-.934-.916a3.404 3.404 0 0 0-4.75 0 3.25 3.25 0 0 0 0 4.66L10 16.666l5.683-6.043a3.25 3.25 0 0 0 0-4.659Z"
|
||||
stroke="currentColor"
|
||||
strokeOpacity={0.7}
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default HeartIcon;
|
||||
@@ -0,0 +1,14 @@
|
||||
function RestartIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="m14.26 3.583.95-.557a.75.75 0 0 0-.758-1.294L11.848 3.26a1 1 0 0 0-.33 1.413l1.53 2.32a.75.75 0 0 0 1.252-.826l-.806-1.224c3.288.687 5.756 3.596 5.756 7.078 0 3.991-3.244 7.23-7.25 7.23s-7.25-3.239-7.25-7.23a7.2 7.2 0 0 1 1.838-4.812c.276-.309.302-.78.023-1.087s-.755-.331-1.037-.027A8.7 8.7 0 0 0 3.25 12.02c0 4.823 3.92 8.73 8.75 8.73s8.75-3.907 8.75-8.73c0-4.044-2.753-7.443-6.49-8.437"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default RestartIcon;
|
||||
@@ -15,6 +15,7 @@ interface IMultiRangeSlider {
|
||||
currentMax: number;
|
||||
step: number;
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
onChangeMin: (min: number) => void;
|
||||
onChangeMax: (max: number) => void;
|
||||
}
|
||||
@@ -27,6 +28,7 @@ function MultiRangeSlider({
|
||||
onChangeMin,
|
||||
onChangeMax,
|
||||
step,
|
||||
label,
|
||||
disabled = false,
|
||||
}: IMultiRangeSlider) {
|
||||
const [current, setCurrent] = useState<"min" | "max" | null>(null);
|
||||
@@ -85,22 +87,25 @@ function MultiRangeSlider({
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="bg-white/80 rounded-lg relative p-3 flex justify-between w-[267px] border !border-[#E2E2DC]">
|
||||
<div className="2xl:space-y-[0.556vw] space-y-2">
|
||||
<p className="text-s">{label}</p>
|
||||
<div className="bg-white/80 rounded-lg relative p-3 flex justify-between border !border-[#E2E2DC]">
|
||||
<p className={clsx("text-s", disabled && "text-[#0D1922]/40")}>
|
||||
{Intl.NumberFormat("en-IN").format(Math.round(currentMin))}
|
||||
{Intl.NumberFormat("en").format(Math.round(currentMin))}
|
||||
</p>
|
||||
<p className={clsx("text-s", disabled && "text-[#0D1922]/40")}>
|
||||
{Intl.NumberFormat("en-IN").format(Math.round(currentMax))}
|
||||
{Intl.NumberFormat("en").format(Math.round(currentMax))}
|
||||
</p>
|
||||
<div className="absolute bottom-0 left-0 translate-y-1/2 w-full px-4">
|
||||
<div className="absolute bottom-0 left-0 w-full px-4 translate-y-1/2">
|
||||
<div
|
||||
className="relative h-4 flex"
|
||||
className="relative flex h-4"
|
||||
ref={rangeRef}
|
||||
onMouseMove={
|
||||
handleChange as React.MouseEventHandler<HTMLDivElement>
|
||||
}
|
||||
onTouchMove={handleChange as React.TouchEventHandler<HTMLDivElement>}
|
||||
onTouchMove={
|
||||
handleChange as React.TouchEventHandler<HTMLDivElement>
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export const projects = [
|
||||
{
|
||||
title: "Rove Home Marasi Drive",
|
||||
img: "/images/search/rove_home_marasi_drive.png",
|
||||
},
|
||||
{
|
||||
title: "Rove Home Dubai Marina",
|
||||
img: "/images/search/rove_home_dubai_marina.png",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number) {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setDebouncedValue(value), delay);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
+7
-9
@@ -5,16 +5,14 @@
|
||||
--breakpoint-2xl: 1440px;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
font-family: "Usual", sans-serif;
|
||||
color: #0d1922;
|
||||
}
|
||||
body {
|
||||
font-family: "Usual", sans-serif;
|
||||
color: #0d1922;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
button {
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
|
||||
@@ -4,9 +4,9 @@ import Footer from "../components/Footer";
|
||||
|
||||
function DefaultLayout() {
|
||||
return (
|
||||
<div className="select-none flex flex-col">
|
||||
<div className="flex flex-col select-none bg-[#F3F3F2]">
|
||||
<Header />
|
||||
<div className="h-[calc(100dvh-64px)] md:max-2xl:h-[calc(100dvh-72px)] 2xl:h-[calc(100dvh-4.444vw)]">
|
||||
<div className="min-h-[calc(100dvh-64px)] md:max-2xl:min-h-[calc(100dvh-72px)] 2xl:min-h-[calc(100dvh-4.444vw)] 2xl:px-[2.222vw] md:max-2xl:px-6 px-4">
|
||||
<Outlet />
|
||||
</div>
|
||||
<Footer />
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { refetchOnWindowFocus: false } },
|
||||
});
|
||||
+6
-2
@@ -1,4 +1,5 @@
|
||||
import "./index.css";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router";
|
||||
import DefaultLayout from "./layout/DefaultLayout.tsx";
|
||||
@@ -11,6 +12,7 @@ import AboutPage from "./pages/AboutPages.tsx";
|
||||
import FavouritesPage from "./pages/FavouritesPage.tsx";
|
||||
import SearchPage from "./pages/SearchPage.tsx";
|
||||
import LayoutWithoutFooter from "./layout/LayoutWithoutFooter.tsx";
|
||||
import { queryClient } from "./lib/queryClient.ts";
|
||||
|
||||
const route = createBrowserRouter([
|
||||
{
|
||||
@@ -55,7 +57,9 @@ const route = createBrowserRouter([
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<>
|
||||
<RouterProvider router={route} />
|
||||
<ModalContainer />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={route} />
|
||||
<ModalContainer />
|
||||
</QueryClientProvider>
|
||||
</>
|
||||
);
|
||||
|
||||
+106
-1
@@ -1,5 +1,110 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { api } from "../api/ky";
|
||||
import { IUnit } from "../types/IUnit";
|
||||
import FlatCard from "../components/FlatCard";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import SearchFilters from "../components/SearchFilters";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
import Button from "../components/ui/Button";
|
||||
import FiltersIcon from "../components/icons/FiltersIcon";
|
||||
import RestartIcon from "../components/icons/RestartIcon";
|
||||
|
||||
const STEP = 12;
|
||||
|
||||
function SearchPage() {
|
||||
return <div>SearchPage</div>;
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const project = searchParams.get("project");
|
||||
const unitTypes = searchParams.getAll("unitTypes");
|
||||
const cost = searchParams.getAll("cost");
|
||||
const floor = searchParams.getAll("floor");
|
||||
const area = searchParams.getAll("area");
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||
useInfiniteQuery({
|
||||
initialPageParam: 0,
|
||||
queryKey: ["units", project, unitTypes, cost, floor, area],
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
await api
|
||||
.get(
|
||||
`units?offset=${pageParam}&limit=${STEP}${
|
||||
project ? `&project=${project}` : ""
|
||||
}${unitTypes.map((unitType) => `&unitTypes=${unitType}`).join("")}${
|
||||
cost.length > 0 ? `&cost=${cost.join(",")}` : ""
|
||||
}${floor.length > 0 ? `&floor=${floor.join(",")}` : ""}${
|
||||
area.length > 0 ? `&area=${area.join(",")}` : ""
|
||||
}`
|
||||
)
|
||||
.json<IUnit[]>(),
|
||||
getNextPageParam: (lastPage, _, lastPageIndex) =>
|
||||
lastPage.length < STEP ? undefined : lastPageIndex + STEP,
|
||||
});
|
||||
|
||||
const filtersRef = useRef<HTMLDivElement>(null);
|
||||
const observerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
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]);
|
||||
|
||||
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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="2xl:pb-[2.222vw] md:max-2xl:pb-6 pb-4 relative">
|
||||
<SearchFilters ref={filtersRef} />
|
||||
<div className="2xl:grid-cols-4 md:max-2xl:grid-cols-2 grid 2xl:gap-[1.111vw] gap-4 2xl:pt-[1.111vw] pt-4">
|
||||
{data?.pages.flat().map((unit) => (
|
||||
<FlatCard key={unit.id} {...unit} />
|
||||
))}
|
||||
</div>
|
||||
<div ref={observerRef} />
|
||||
{showButtons && (
|
||||
<div className="fixed 2xl:bottom-[2.222vw] left-1/2 -translate-x-1/2 flex 2xl:gap-[0.278vw] gap-2">
|
||||
<Button>
|
||||
<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>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="2xl:!outline-[0.069vw] !outline !outline-[#E2E2DC]"
|
||||
onClick={() => navigate("/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>
|
||||
);
|
||||
}
|
||||
|
||||
export default SearchPage;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface IUnit {
|
||||
id: string;
|
||||
unitNo: string;
|
||||
project: string;
|
||||
floor: string;
|
||||
unitType: string;
|
||||
noOfBathrooms: number;
|
||||
unitView: string;
|
||||
suitsArea: number;
|
||||
squareFt: number;
|
||||
noOfParkingSpace: number;
|
||||
salesPrice: number;
|
||||
state: string;
|
||||
balconyArea: number;
|
||||
}
|
||||
+1
-1
@@ -6,6 +6,6 @@ import tailwindcss from "@tailwindcss/vite";
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
// host: true,
|
||||
host: true,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user