diff --git a/.env b/.env new file mode 100644 index 0000000..cd41370 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +VITE_API_URL=http://localhost:3000 \ No newline at end of file diff --git a/bun.lock b/bun.lock index 06b217b..2c0561c 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 4c1b0ce..49a8c99 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/images/search/rove_home_dubai_marina.png b/public/images/search/rove_home_dubai_marina.png new file mode 100644 index 0000000..4ab718b Binary files /dev/null and b/public/images/search/rove_home_dubai_marina.png differ diff --git a/public/images/search/rove_home_marasi_drive.png b/public/images/search/rove_home_marasi_drive.png new file mode 100644 index 0000000..22d2c60 Binary files /dev/null and b/public/images/search/rove_home_marasi_drive.png differ diff --git a/src/api/ky.ts b/src/api/ky.ts new file mode 100644 index 0000000..6ba63a5 --- /dev/null +++ b/src/api/ky.ts @@ -0,0 +1,5 @@ +import ky from "ky"; + +export const api = ky.create({ + prefixUrl: import.meta.env.VITE_API_URL, +}); diff --git a/src/components/Compass.tsx b/src/components/Compass.tsx index b29f03c..9c2e39b 100644 --- a/src/components/Compass.tsx +++ b/src/components/Compass.tsx @@ -10,7 +10,7 @@ function Compass({ imgStyle }: CompassProps) { diff --git a/src/components/FlatCard.tsx b/src/components/FlatCard.tsx new file mode 100644 index 0000000..1686fd5 --- /dev/null +++ b/src/components/FlatCard.tsx @@ -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 ( +
+
+
+

{project}

+
+

+ {(unitNo.split("-")[0] === "W" ? "West" : "East") + " Wing"} +

+
+

Floor {floor}

+
+

{unitNo}

+
+
+ +
+
+

+ {unitType},{" "} + {squareFt.toLocaleString(undefined, { maximumFractionDigits: 2 })}{" "} + Sqft +

+

+ AED {Intl.NumberFormat("en").format(salesPrice)} +

+
+
+ ); +} + +export default FlatCard; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 15c69cb..01e90d8 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -7,7 +7,7 @@ import TwitterIcon from "./icons/TwitterIcon"; function Footer() { return ( -
-
+
@@ -84,7 +84,7 @@ function NavItem({ href, title }: { href: string; title: string }) { function ProfileBar() { return ( - ); diff --git a/src/components/Map.tsx b/src/components/Map.tsx index 7ee43d7..f05e8ff 100644 --- a/src/components/Map.tsx +++ b/src/components/Map.tsx @@ -561,7 +561,7 @@ function Map({ maxZoom = 1 }: MapProps) { return (
-
+
diff --git a/src/components/ProjectFilter.tsx b/src/components/ProjectFilter.tsx new file mode 100644 index 0000000..68566a2 --- /dev/null +++ b/src/components/ProjectFilter.tsx @@ -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 ( +
{ + 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]" + )} + > + +

{title}

+
+ ); +} + +export default ProjectFilter; diff --git a/src/components/SearchFilters.tsx b/src/components/SearchFilters.tsx new file mode 100644 index 0000000..c0e9b24 --- /dev/null +++ b/src/components/SearchFilters.tsx @@ -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; +}) { + const [project, setProject] = useState(); + const [unitTypes, setUnitTypes] = useState([]); + + const [searchParams, setSearchParams] = useSearchParams(); + + const navigate = useNavigate(); + + const { data: filters } = useQuery({ + queryKey: ["filters", project], + queryFn: () => + api + .get(`units/filters?${project ? `project=${project}` : ""}`) + .json(), + }); + + 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(); + const [currentMaxCost, setCurrentMaxCost] = useState(); + + const [currentMinArea, setCurrentMinArea] = useState(); + const [currentMaxArea, setCurrentMaxArea] = useState(); + + const [currentMinFloor, setCurrentMinFloor] = useState(); + const [currentMaxFloor, setCurrentMaxFloor] = useState(); + + 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(), + }); + + 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 ( +
+
+

+ Search +

+
+ {projects.map((project) => ( + + ))} +
+
+
+ {filters && ( + <> +
+

Apartment type

+
+ {filters.unitTypes.map((unitType) => ( + + ))} +
+
+
+ + + +
+
+ {inModal ? ( + + ) : ( +

{count} Apartments found

+ )} + +
+ + )} +
+ ); +} + +export default SearchFilters; diff --git a/src/components/UnitTypesFilter.tsx b/src/components/UnitTypesFilter.tsx new file mode 100644 index 0000000..6475539 --- /dev/null +++ b/src/components/UnitTypesFilter.tsx @@ -0,0 +1,29 @@ +import clsx from "clsx"; +import { useSearchParams } from "react-router"; + +function UnitTypesFilter({ title }: { title: string }) { + const [searchParams, setSearchParams] = useSearchParams(); + + return ( +
{ + 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} +
+ ); +} + +export default UnitTypesFilter; diff --git a/src/components/WeatherWidget.tsx b/src/components/WeatherWidget.tsx index ad39391..ff9dd77 100644 --- a/src/components/WeatherWidget.tsx +++ b/src/components/WeatherWidget.tsx @@ -18,20 +18,20 @@ export default function WeatherWidget({ const formattedTime = `${hours}:${minutes}`; return ( -
+

{day}

{formattedTime}

-
+

{date.getDate()} {month}

{dayPart}

-
+

{temperature}°C

); diff --git a/src/components/icons/FiltersIcon.tsx b/src/components/icons/FiltersIcon.tsx new file mode 100644 index 0000000..2206a29 --- /dev/null +++ b/src/components/icons/FiltersIcon.tsx @@ -0,0 +1,12 @@ +function FiltersIcon() { + return ( + + + + ); +} + +export default FiltersIcon; diff --git a/src/components/icons/HeartIcon.tsx b/src/components/icons/HeartIcon.tsx new file mode 100644 index 0000000..8707771 --- /dev/null +++ b/src/components/icons/HeartIcon.tsx @@ -0,0 +1,15 @@ +function HeartIcon() { + return ( + + + + ); +} + +export default HeartIcon; diff --git a/src/components/icons/RestartIcon.tsx b/src/components/icons/RestartIcon.tsx new file mode 100644 index 0000000..cc36c16 --- /dev/null +++ b/src/components/icons/RestartIcon.tsx @@ -0,0 +1,14 @@ +function RestartIcon() { + return ( + + + + ); +} + +export default RestartIcon; diff --git a/src/components/ui/MultiRangeSlider.tsx b/src/components/ui/MultiRangeSlider.tsx index 6ecad23..f617f42 100644 --- a/src/components/ui/MultiRangeSlider.tsx +++ b/src/components/ui/MultiRangeSlider.tsx @@ -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 ( -
-
+
+

{label}

+

- {Intl.NumberFormat("en-IN").format(Math.round(currentMin))} + {Intl.NumberFormat("en").format(Math.round(currentMin))}

- {Intl.NumberFormat("en-IN").format(Math.round(currentMax))} + {Intl.NumberFormat("en").format(Math.round(currentMax))}

-
+
} - onTouchMove={handleChange as React.TouchEventHandler} + onTouchMove={ + handleChange as React.TouchEventHandler + } >
(value: T, delay: number) { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timeout = setTimeout(() => setDebouncedValue(value), delay); + + return () => clearTimeout(timeout); + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/index.css b/src/index.css index 7ef378e..23f941d 100644 --- a/src/index.css +++ b/src/index.css @@ -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 { diff --git a/src/layout/DefaultLayout.tsx b/src/layout/DefaultLayout.tsx index 25d0549..f6aa544 100644 --- a/src/layout/DefaultLayout.tsx +++ b/src/layout/DefaultLayout.tsx @@ -4,9 +4,9 @@ import Footer from "../components/Footer"; function DefaultLayout() { return ( -
+
-
+