diff --git a/client/package.json b/client/package.json index 13260dce..42bffcb3 100644 --- a/client/package.json +++ b/client/package.json @@ -12,11 +12,13 @@ "@hookform/resolvers": "3.9.0", "@lukemorales/query-key-factory": "1.3.4", "@radix-ui/react-alert-dialog": "1.1.2", + "@radix-ui/react-checkbox": "1.1.2", "@radix-ui/react-dialog": "1.1.2", "@radix-ui/react-icons": "1.3.0", "@radix-ui/react-label": "2.1.0", "@radix-ui/react-select": "2.1.2", "@radix-ui/react-separator": "1.1.0", + "@radix-ui/react-slider": "1.2.1", "@radix-ui/react-slot": "1.1.0", "@radix-ui/react-tabs": "1.1.1", "@radix-ui/react-toast": "1.2.2", diff --git a/client/src/app/(projects)/constants.ts b/client/src/app/(projects)/constants.ts index 6f3e9bbc..d2888353 100644 --- a/client/src/app/(projects)/constants.ts +++ b/client/src/app/(projects)/constants.ts @@ -15,4 +15,10 @@ export const FILTER_KEYS = [ "projectSizeFilter", "priceType", "totalCost", + "countryCode", + "ecosystem", + "activity", + "activitySubtype", + "cost", + "abatementPotential", ] as const; diff --git a/client/src/app/(projects)/page.tsx b/client/src/app/(projects)/page.tsx index 6f11e548..e68f4beb 100644 --- a/client/src/app/(projects)/page.tsx +++ b/client/src/app/(projects)/page.tsx @@ -5,10 +5,14 @@ import { useMap } from "react-map-gl"; import { motion } from "framer-motion"; import { useAtomValue } from "jotai"; +import { cn } from "@/lib/utils"; + import { LAYOUT_TRANSITIONS } from "@/app/(projects)/constants"; import { projectsUIState } from "@/app/(projects)/store"; -import ProjectsFilters from "@/containers/projects/filters"; +import ProjectsFilters, { + FILTERS_SIDEBAR_WIDTH, +} from "@/containers/projects/filters"; import ProjectsHeader from "@/containers/projects/header"; import ProjectsMap from "@/containers/projects/map"; @@ -21,6 +25,7 @@ import { useSidebar } from "@/components/ui/sidebar"; import ProjectsTable from "src/containers/projects/table"; const PANEL_MIN_SIZE = 25; +const PANEL_DEFAULT_SIZE = 50; export default function Projects() { const { filtersOpen } = useAtomValue(projectsUIState); @@ -37,7 +42,9 @@ export default function Projects() { @@ -75,7 +82,7 @@ export default function Projects() { diff --git a/client/src/app/(projects)/url-store.ts b/client/src/app/(projects)/url-store.ts index fd8d3d4d..07f13005 100644 --- a/client/src/app/(projects)/url-store.ts +++ b/client/src/app/(projects)/url-store.ts @@ -1,9 +1,6 @@ -import { - parseAsJson, - parseAsString, - parseAsStringLiteral, - useQueryState, -} from "nuqs"; +import { ACTIVITY, RESTORATION_ACTIVITY } from "@shared/entities/activity.enum"; +import { ECOSYSTEM } from "@shared/entities/ecosystem.enum"; +import { parseAsJson, parseAsStringLiteral, useQueryState } from "nuqs"; import { z } from "zod"; import { @@ -15,29 +12,41 @@ import { import { TABLE_VIEWS } from "@/containers/projects/table/toolbar/table-selector"; +const SUB_ACTIVITIES = RESTORATION_ACTIVITY; + export const filtersSchema = z.object({ [FILTER_KEYS[0]]: z.string().optional(), [FILTER_KEYS[1]]: z.enum(PROJECT_SIZE_VALUES), [FILTER_KEYS[2]]: z.enum(CARBON_PRICING_TYPE_VALUES), [FILTER_KEYS[3]]: z.enum(COST_VALUES), + [FILTER_KEYS[4]]: z.string().optional(), + [FILTER_KEYS[5]]: z.array(z.nativeEnum(ECOSYSTEM)), + [FILTER_KEYS[6]]: z.array(z.nativeEnum(ACTIVITY)), + [FILTER_KEYS[7]]: z.array(z.nativeEnum(SUB_ACTIVITIES)), + [FILTER_KEYS[8]]: z.array(z.number()).length(2), + [FILTER_KEYS[9]]: z.array(z.number()).length(2), }); +export const INITIAL_FILTERS_STATE: z.infer = { + keyword: "", + projectSizeFilter: "medium", + priceType: "market_price", + totalCost: "npv", + countryCode: "", + ecosystem: [], + activity: [], + activitySubtype: [], + cost: [0, 0], + abatementPotential: [0, 0], +}; + export function useGlobalFilters() { return useQueryState( "filters", - parseAsJson(filtersSchema.parse).withDefault({ - keyword: "", - projectSizeFilter: "medium", - priceType: "market_price", - totalCost: "npv", - }), + parseAsJson(filtersSchema.parse).withDefault(INITIAL_FILTERS_STATE), ); } -export function useSyncCountry() { - return useQueryState("country", parseAsString.withDefault("")); -} - export function useTableView() { return useQueryState( "table", diff --git a/client/src/components/ui/checkbox.tsx b/client/src/components/ui/checkbox.tsx new file mode 100644 index 00000000..69e78270 --- /dev/null +++ b/client/src/components/ui/checkbox.tsx @@ -0,0 +1,31 @@ +"use client"; + +import * as React from "react"; + +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { CheckIcon } from "@radix-ui/react-icons"; + +import { cn } from "@/lib/utils"; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/client/src/components/ui/slider.tsx b/client/src/components/ui/slider.tsx new file mode 100644 index 00000000..d70c4235 --- /dev/null +++ b/client/src/components/ui/slider.tsx @@ -0,0 +1,68 @@ +"use client"; + +import * as React from "react"; + +import * as SliderPrimitive from "@radix-ui/react-slider"; + +import { cn } from "@/lib/utils"; + +const Thumb = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +Thumb.displayName = SliderPrimitive.Thumb.displayName; + +const Slider = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + + +)); + +Slider.displayName = SliderPrimitive.Root.displayName; + +const RangeSlider = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + + + +)); + +RangeSlider.displayName = SliderPrimitive.Root.displayName; + +export { Slider, RangeSlider }; diff --git a/client/src/containers/projects/filters/index.tsx b/client/src/containers/projects/filters/index.tsx index 66abbe58..a0443f36 100644 --- a/client/src/containers/projects/filters/index.tsx +++ b/client/src/containers/projects/filters/index.tsx @@ -1,21 +1,327 @@ +import { CheckedState } from "@radix-ui/react-checkbox"; +import { ACTIVITY, RESTORATION_ACTIVITY } from "@shared/entities/activity.enum"; +import { ECOSYSTEM } from "@shared/entities/ecosystem.enum"; +import { useSetAtom } from "jotai/index"; +import { XIcon } from "lucide-react"; +import { useDebounce } from "rooks"; + +import { client } from "@/lib/query-client"; +import { queryKeys } from "@/lib/query-keys"; + +import { projectsUIState } from "@/app/(projects)/store"; +import { + INITIAL_FILTERS_STATE, + useGlobalFilters, +} from "@/app/(projects)/url-store"; + +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { RangeSlider } from "@/components/ui/slider"; + +const COST_RANGE = [1200, 2300]; +const ABATEMENT_POTENTIAL_RANGE = [0, 100]; + +export const FILTERS_SIDEBAR_WIDTH = 320; + export default function ProjectsFilters() { + const [filters, setFilters] = useGlobalFilters(); + const setFiltersOpen = useSetAtom(projectsUIState); + + const resetFilters = async () => { + await setFilters((prev) => ({ + ...prev, + countryCode: INITIAL_FILTERS_STATE.countryCode, + ecosystem: INITIAL_FILTERS_STATE.ecosystem, + activities: INITIAL_FILTERS_STATE.activity, + activitySubtype: INITIAL_FILTERS_STATE.activitySubtype, + abatementPotential: INITIAL_FILTERS_STATE.abatementPotential, + cost: INITIAL_FILTERS_STATE.cost, + })); + }; + const closeFilters = () => { + setFiltersOpen((prev) => ({ ...prev, filtersOpen: false })); + }; + + const { queryKey } = queryKeys.projects.countries; + const { data: countryOptions } = client.projects.getProjectCountries.useQuery( + queryKey, + {}, + { + queryKey, + select: (data) => + data.body.data.map(({ name, code }) => ({ label: name, value: code })), + }, + ); + + const handleEcosystemChange = async ( + isChecked: CheckedState, + ecosystem: ECOSYSTEM, + ) => { + await setFilters((prev) => ({ + ...prev, + ecosystem: isChecked + ? [...prev.ecosystem, ecosystem] + : prev.ecosystem.filter((e) => e !== ecosystem), + })); + }; + + const handleActivityChange = async ( + isChecked: CheckedState, + activity: ACTIVITY, + ) => { + await setFilters((prev) => ({ + ...prev, + activity: isChecked + ? [...prev.activity, activity] + : prev.activity.filter((e) => e !== activity), + })); + }; + + const handleSubActivityChange = async ( + isChecked: CheckedState, + subActivity: (typeof filters.activitySubtype)[number], + ) => { + await setFilters((prev) => ({ + ...prev, + activitySubtype: isChecked + ? [...prev.activitySubtype, subActivity] + : prev.activitySubtype.filter((e) => e !== subActivity), + })); + }; + + const debouncedCostChange = useDebounce(async (cost: [number, number]) => { + await setFilters((prev) => ({ + ...prev, + cost, + })); + }, 250); + + const debouncedAbatementPotentialChange = useDebounce( + async (abatementPotential: [number, number]) => { + await setFilters((prev) => ({ + ...prev, + abatementPotential, + })); + }, + 250, + ); + return ( -
- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam nec pur - us. Ut sit amet libero auctor, tincidunt mauris sit amet, ultricies - turpis. Donec nec nulla in purus Lorem ipsum dolor sit amet, consectetur - adipiscing elit. Nullam nec pur us. Ut sit amet libero auctor, tincidunt - mauris sit amet, ultricies turpis. Donec nec nulla in purus Lorem ipsum - dolor sit amet, consectetur adipiscing elit. Nullam nec pur us. Ut sit - amet libero auctor, tincidunt mauris sit amet, ultricies turpis. Donec nec - nulla in purus Lorem ipsum dolor sit amet, consectetur adipiscing elit. - Nullam nec pur us. Ut sit amet libero auctor, tincidunt mauris sit amet, - ultricies turpis. Donec nec nulla in purus Lorem ipsum dolor sit amet, - consectetur adipiscing elit. Nullam nec pur us. Ut sit amet libero auctor, - tincidunt mauris sit amet, ultricies turpis. Donec nec nulla in purus - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam nec pur - us. Ut sit amet libero auctor, tincidunt mauris sit amet, ultricies - turpis. Donec nec nulla in purus -
+
+
+

Filters

+ + +
+
+ + +
+
+

Ecosystems

+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+

Activity type

+
    +
  • + +
  • +
  • + + +
      +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    +
  • +
+
+
+ + +
+ {COST_RANGE[0]} + {COST_RANGE[1]} +
+
+ +
+ + +
+ {ABATEMENT_POTENTIAL_RANGE[0]} + {ABATEMENT_POTENTIAL_RANGE[1]} +
+
+
); } diff --git a/client/src/containers/projects/map/layers/projects/index.tsx b/client/src/containers/projects/map/layers/projects/index.tsx index 2fc40ffb..54603b30 100644 --- a/client/src/containers/projects/map/layers/projects/index.tsx +++ b/client/src/containers/projects/map/layers/projects/index.tsx @@ -6,22 +6,26 @@ import { FillLayerSpecification } from "mapbox-gl"; import { client } from "@/lib/query-client"; import { geometriesKeys } from "@/lib/query-keys"; -import { useSyncCountry } from "@/app/(projects)/url-store"; +import { useGlobalFilters } from "@/app/(projects)/url-store"; import { generateColorRamp } from "@/containers/projects/map/layers/projects/utils"; export default function ProjectsLayer() { - const [country] = useSyncCountry(); + const [filters] = useGlobalFilters(); - const queryKey = country - ? geometriesKeys.country(country).queryKey - : geometriesKeys.all.queryKey; + const queryKey = geometriesKeys.all(filters).queryKey; const { data, isSuccess } = client.projects.getProjectsMap.useQuery( queryKey, { query: { - filter: { ...(country && { countryCode: [country] }) }, + filter: { + ...(filters.countryCode && { countryCode: [filters.countryCode] }), + totalCost: filters.cost, + abatementPotential: filters.abatementPotential, + ecosystem: filters.ecosystem, + activity: filters.activity, + }, }, }, { diff --git a/client/src/lib/query-keys.ts b/client/src/lib/query-keys.ts index 4a1e2b31..921a4ee9 100644 --- a/client/src/lib/query-keys.ts +++ b/client/src/lib/query-keys.ts @@ -19,8 +19,7 @@ export const userKeys = createQueryKeys("user", { }); export const geometriesKeys = createQueryKeys("geometries", { - all: null, - country: (country: string) => ["country", country], + all: (filters: z.infer) => [filters], }); export const projectKeys = createQueryKeys("projects", { @@ -32,6 +31,7 @@ export const projectKeys = createQueryKeys("projects", { }, ) => ["all", tableView, filters], id: (id: string) => [id], + countries: null, }); export const queryKeys = mergeQueryKeys( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d272c2ff..019d0fbd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -282,6 +282,9 @@ importers: '@radix-ui/react-alert-dialog': specifier: 1.1.2 version: 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-checkbox': + specifier: 1.1.2 + version: 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-dialog': specifier: 1.1.2 version: 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -297,6 +300,9 @@ importers: '@radix-ui/react-separator': specifier: 1.1.0 version: 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slider': + specifier: 1.2.1 + version: 1.2.1(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': specifier: 1.1.0 version: 1.1.0(@types/react@18.3.5)(react@18.3.1) @@ -2061,6 +2067,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-checkbox@1.1.2': + resolution: {integrity: sha512-/i0fl686zaJbDQLNKrkCbMyDm6FQMt4jg323k7HuqitoANm9sE23Ql8yOK3Wusk34HSLKDChhMux05FnP6KUkw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collection@1.1.0': resolution: {integrity: sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==} peerDependencies: @@ -2276,6 +2295,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-slider@1.2.1': + resolution: {integrity: sha512-bEzQoDW0XP+h/oGbutF5VMWJPAl/UU8IJjr7h02SOHDIIIxq+cep8nItVNoBV+OMmahCdqdF38FTpmXoqQUGvw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.1.0': resolution: {integrity: sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==} peerDependencies: @@ -10020,6 +10052,22 @@ snapshots: '@types/react': 18.3.5 '@types/react-dom': 18.3.0 + '@radix-ui/react-checkbox@1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-presence': 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.0(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.0(@types/react@18.3.5)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.5 + '@types/react-dom': 18.3.0 + '@radix-ui/react-collection@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.5)(react@18.3.1) @@ -10230,6 +10278,25 @@ snapshots: '@types/react': 18.3.5 '@types/react-dom': 18.3.0 + '@radix-ui/react-slider@1.2.1(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/number': 1.1.0 + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-collection': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.0(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.0(@types/react@18.3.5)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.5 + '@types/react-dom': 18.3.0 + '@radix-ui/react-slot@1.1.0(@types/react@18.3.5)(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.5)(react@18.3.1) diff --git a/shared/dtos/projects/projects-map.dto.ts b/shared/dtos/projects/projects-map.dto.ts index bf83e828..c4a37dc3 100644 --- a/shared/dtos/projects/projects-map.dto.ts +++ b/shared/dtos/projects/projects-map.dto.ts @@ -16,9 +16,9 @@ export type ProjectMapFilters = { countryCode?: string[]; totalCost?: number[]; abatementPotential?: number[]; - activity?: ACTIVITY; + activity?: ACTIVITY[]; activitySubtype?: string[]; - ecosystem?: ECOSYSTEM; + ecosystem?: ECOSYSTEM[]; projectSizeFilter?: PROJECT_SIZE_FILTER; priceType?: PROJECT_PRICE_TYPE; }; diff --git a/shared/entities/activity.enum.ts b/shared/entities/activity.enum.ts index 2e0cb11a..68cc3494 100644 --- a/shared/entities/activity.enum.ts +++ b/shared/entities/activity.enum.ts @@ -2,3 +2,9 @@ export enum ACTIVITY { RESTORATION = "Restoration", CONSERVATION = "Conservation", } + +export enum RESTORATION_ACTIVITY { + HYBRID = "Hybrid", + HYDROLOGY = "Hydrology", + PLANTING = "Planting", +}