Implement region based filtering of clubs

replace cancel button with X

use search params instead of path

fix routes for filtering clubs by URL, add loading map spinner, remove useEffect

normalised region name in url will set filter

search region by url params

add i8n to region filtering

use jotai useSetAtom

select value becomes the name of the region on selection

fix geojson types

fix region names

apply same regional filtering logic to tournaments that we have for clubs

type fixes for GeoJson Feature

basic filtering of clubs by region working

remove comments

two column layout

change text colours

accept undefined in region data state

selecting a region from modal sets state with region data of Feature type. Types currently not all correct.

by region filter satisfies types, with no functionality yet

remove unused imports

add region option in dropdown

rename file to reflect its function

json file for region names

comment out analytics script for testing loading

upgrade mongo

unformat file to keep line count small

add json of French regions coordinates

upgrade next to last v14
This commit is contained in:
2025-08-30 22:51:53 +02:00
parent 780e1fcf39
commit c27fffd1b2
12 changed files with 339 additions and 103 deletions
+31 -1
View File
@@ -1,10 +1,15 @@
"use client";
import { Feature, GeoJsonProperties, MultiPolygon, Polygon } from "geojson";
import { useSetAtom } from "jotai";
import { useHydrateAtoms } from "jotai/utils";
import dynamic from "next/dynamic";
import { useSearchParams } from "next/navigation";
import { clubsAtom } from "@/atoms";
import { clubsAtom, regionFilterAtom } from "@/atoms";
import LoadingMap from "@/components/LoadingMap";
import regionNames from "@/resources/regionNames.json";
import regionGeoJson from "@/resources/regionsGeoJson.json";
import { Club } from "@/types";
import ClubTable from "./ClubTable";
@@ -24,6 +29,31 @@ const ClubMap = dynamic(() => import("./ClubMap"), {
export default function ClubsDisplay({ clubs }: ClubsDisplayProps) {
useHydrateAtoms([[clubsAtom, clubs]]);
const setRegionFilter = useSetAtom(regionFilterAtom);
const searchParams = useSearchParams();
const regionSearchParam = searchParams.get("region");
if (regionSearchParam) {
const matchedRegion = regionNames.name.find(
(name) =>
name
.toLowerCase()
.replaceAll(" ", "-")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "") === regionSearchParam.toLowerCase(),
);
const regionData = regionGeoJson.features.find(
(f) => f.properties.nom === matchedRegion,
);
if (regionData) {
setRegionFilter(
regionData as Feature<Polygon | MultiPolygon, GeoJsonProperties>,
);
}
}
return (
<main className="relative grid h-full w-full lg:grid-cols-2">
+4 -4
View File
@@ -80,10 +80,10 @@ export default async function RootLayout({
<Footer />
</NextIntlClientProvider>
<Script
defer
src="https://app.tinyanalytics.io/pixel/HyoumUokLr9exPgX"
/>
{/*<Script*/}
{/* defer*/}
{/* src="https://app.tinyanalytics.io/pixel/HyoumUokLr9exPgX"*/}
{/*/>*/}
</Providers>
</body>
</html>
+49 -3
View File
@@ -8,7 +8,13 @@ import {
startOfDay,
} from "date-fns";
import { fr } from "date-fns/locale";
import { FeatureCollection } from "geojson";
import {
Feature,
FeatureCollection,
GeoJsonProperties,
MultiPolygon,
Polygon,
} from "geojson";
import { atom } from "jotai";
import { LatLngBounds } from "leaflet";
@@ -21,12 +27,25 @@ setDefaultOptions({ locale: fr });
type RegionFilter =
| "all"
| "map"
| "region"
| Feature<Polygon | MultiPolygon, GeoJsonProperties>
| {
id: string;
name: string;
features: FeatureCollection;
};
export function isFeature(obj: any): obj is Feature {
return (
obj &&
typeof obj === "object" &&
obj.type === "Feature" &&
obj.properties !== undefined &&
obj.geometry !== undefined &&
(obj.geometry.type === "Polygon" || obj.geometry.type === "MultiPolygon")
);
}
export const burgerMenuIsOpenAtom = atom(false);
export const mapBoundsAtom = atom<LatLngBounds | null>(null);
export const regionFilterAtom = atom<RegionFilter>("map");
@@ -79,9 +98,22 @@ export const filteredTournamentsByTimeControlAndZoneAtom = atom((get) => {
);
});
if (regionFilter === "all" || regionFilter === "map")
if (
regionFilter === "all" ||
regionFilter === "map" ||
regionFilter === "region"
)
return filterByTimeControl;
if (isFeature(regionFilter)) {
return filterByTimeControl.filter((tournament) => {
return booleanPointInPolygon(
[tournament.latLng.lng, tournament.latLng.lat],
regionFilter.geometry,
);
});
}
return filterByTimeControl.filter((tournament) => {
return regionFilter.features?.features?.some(
(feature) =>
@@ -127,7 +159,21 @@ export const filteredClubsByZoneAtom = atom((get) => {
const clubs = get(clubsAtom);
const regionFilter = get(regionFilterAtom);
if (regionFilter === "all" || regionFilter === "map") return clubs;
if (
regionFilter === "all" ||
regionFilter === "map" ||
regionFilter === "region"
)
return clubs;
if (isFeature(regionFilter)) {
return clubs.filter((club) => {
return booleanPointInPolygon(
[club.latLng.lng, club.latLng.lat],
regionFilter.geometry,
);
});
}
return clubs.filter((club) => {
return regionFilter.features?.features?.some(
+58 -23
View File
@@ -1,15 +1,12 @@
import gd from "date-fns/locale/gd";
import { useState } from "react";
import { useAtom } from "jotai";
import { useTranslations } from "next-intl";
import { IoAdd } from "react-icons/io5";
import {
GroupBase,
OnChangeValue,
OptionsOrGroups,
SingleValue,
} from "react-select";
import { GroupBase, OnChangeValue, OptionsOrGroups } from "react-select";
import { regionFilterAtom } from "@/atoms";
import { isFeature, regionFilterAtom } from "@/atoms";
import { RegionSelectModal } from "@/components/RegionSelectModal";
import { BaseOption, Select, SelectProps } from "@/components/form/Select";
import { useZones } from "@/hooks/useZones";
import { Zone } from "@/server/myZones";
@@ -32,6 +29,7 @@ export const RegionSelect = ({
const t = useTranslations("Zones");
const router = useRouter();
const [regionFilter, setRegionFilter] = useAtom(regionFilterAtom);
const [isRegionModalOpen, setIsRegionModalOpen] = useState(false);
const { zones } = useZones();
const zoneFilterOptions: OptionsOrGroups<RegionOption, GroupedOption> = [
@@ -40,6 +38,11 @@ export const RegionSelect = ({
label: syncTitle,
data: null,
},
{
value: "region",
label: t("RegionFilter.regionSelectValue"),
data: null,
},
{
value: "all",
label: t("ignoreMapOption"),
@@ -68,14 +71,22 @@ export const RegionSelect = ({
option: OnChangeValue<BaseOption<string, Zone | null>, false>,
) => {
if (!option) return;
if (option.value === "create") {
router.push("/zones/create");
return;
}
if (option.value === "map" || option.value === "all")
if (option.value === "region") {
setIsRegionModalOpen(true);
return;
}
if (option.value === "map" || option.value === "all") {
setRegionFilter(option.value);
else setRegionFilter(option.data!);
} else {
setRegionFilter(option.data!);
}
};
const formatGroupLabel = (data: GroupedOption) => (
@@ -102,19 +113,43 @@ export const RegionSelect = ({
"options" in groupOrOption ? groupOrOption.options : groupOrOption,
);
const getValue = () => {
if (
regionFilter === "all" ||
regionFilter === "map" ||
regionFilter === "region"
) {
return allOptions.find((o) => o.value === regionFilter);
}
if (isFeature(regionFilter) && regionFilter.properties) {
// Display the region name from properties.nom when it's a Feature
return {
value: "region",
label: regionFilter.properties.nom,
data: null,
};
}
return allOptions.find((o) => o.value === regionFilter.id);
};
return (
<Select
options={zoneFilterOptions}
value={allOptions.find((o) =>
regionFilter === "all" || regionFilter === "map"
? o.value === regionFilter
: o.value === regionFilter.id,
)}
isMulti={false}
formatGroupLabel={formatGroupLabel}
formatOptionLabel={formatOptionLabel}
onChange={onChange}
{...selectProps}
/>
<>
<Select
options={zoneFilterOptions}
value={getValue()}
isMulti={false}
formatGroupLabel={formatGroupLabel}
formatOptionLabel={formatOptionLabel}
onChange={onChange}
{...selectProps}
/>
<RegionSelectModal
isRegionModalOpen={isRegionModalOpen}
setIsRegionModalOpen={setIsRegionModalOpen}
/>
</>
);
};
+96
View File
@@ -0,0 +1,96 @@
import type { Dispatch, SetStateAction } from "react";
import {
type Feature,
GeoJsonProperties,
MultiPolygon,
Polygon,
} from "geojson";
import { useSetAtom } from "jotai";
import { useTranslations } from "next-intl";
import { IoClose } from "react-icons/io5";
import { regionFilterAtom } from "@/atoms";
import { Modal } from "@/components/Modal";
import regionNames from "@/resources/regionNames.json";
import regionGeoJson from "@/resources/regionsGeoJson.json";
interface IProps {
isRegionModalOpen: boolean;
setIsRegionModalOpen: Dispatch<SetStateAction<boolean>>;
}
export const RegionSelectModal = ({
isRegionModalOpen,
setIsRegionModalOpen,
}: IProps) => {
const t = useTranslations("Zones");
const setRegionFilter = useSetAtom(regionFilterAtom);
const handleCloseModal = () => {
setIsRegionModalOpen(false);
};
const handleRegionSelect = (region: string) => {
const regionData = regionGeoJson.features.find(
(f) => f.properties.nom === region,
);
if (regionData) {
setRegionFilter(
regionData as Feature<Polygon | MultiPolygon, GeoJsonProperties>,
);
handleCloseModal();
}
};
const regions = [...regionNames.name];
const midPoint = Math.ceil(regions.length / 2);
const leftColumnRegions = regions.slice(0, midPoint);
const rightColumnRegions = regions.slice(midPoint);
return (
<Modal
open={isRegionModalOpen}
onClose={handleCloseModal}
title={t("RegionFilter.regionModalTitle")}
panelClass="max-w-3xl relative"
>
<button
onClick={handleCloseModal}
className="absolute right-4 top-0 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
aria-label="Close"
>
<IoClose size={24} />
</button>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-3">
{leftColumnRegions.map((region) => (
<button
key={region}
onClick={() => handleRegionSelect(region)}
className="w-full rounded-lg border border-gray-300 p-3 text-left text-gray-900 hover:bg-gray-100 dark:border-gray-500 dark:text-gray-100 dark:hover:bg-gray-700"
>
{region}
</button>
))}
</div>
<div className="space-y-3">
{rightColumnRegions.map((region) => (
<button
key={region}
onClick={() => handleRegionSelect(region)}
className="w-full rounded-lg border border-gray-300 p-3 text-left text-gray-900 hover:bg-gray-100 dark:border-gray-500 dark:text-gray-100 dark:hover:bg-gray-700"
>
{region}
</button>
))}
</div>
</div>
</div>
</Modal>
);
};
+6 -1
View File
@@ -241,7 +241,12 @@
"createFailure": "Oops, something went wrong. Please try again later.",
"deleteZoneTitle": "Delete Zone",
"deleteZoneInfo": "Are you sure you want to delete this zone? This action cannot be undone."
"deleteZoneInfo": "Are you sure you want to delete this zone? This action cannot be undone.",
"RegionFilter": {
"regionSelectValue": "By region",
"regionModalTitle": "Select region"
}
},
"SignIn": {
+6 -1
View File
@@ -243,7 +243,12 @@
"createFailure": "Oups, quelque chose s'est mal passé. Veuillez réessayer.",
"deleteZoneTitle": "Supprimer la région",
"deleteZoneInfo": "Êtes-vous sûr de vouloir supprimer cette région\u00a0?"
"deleteZoneInfo": "Êtes-vous sûr de vouloir supprimer cette région\u00a0?",
"RegionFilter": {
"regionSelectValue": "Par région",
"regionModalTitle": "Selectionnez une région"
}
},
"SignIn": {
+17
View File
@@ -0,0 +1,17 @@
{
"name": [
"Auvergne-Rhône-Alpes",
"Bourgogne-Franche-Comté",
"Bretagne",
"Centre-Val de Loire",
"Corse",
"Grand Est",
"Hauts-de-France",
"Île-de-France",
"Normandie",
"Nouvelle-Aquitaine",
"Occitanie",
"Pays de la Loire",
"Provence-Alpes-Côte dAzur"
]
}
File diff suppressed because one or more lines are too long