diff --git a/next.config.js b/next.config.js index b1689fd..3eb5b94 100644 --- a/next.config.js +++ b/next.config.js @@ -9,6 +9,7 @@ const withNextIntl = require("next-intl/plugin")("./src/i18n.ts"); const withPWA = require("next-pwa")({ dest: "public", + disable: process.env.NODE_ENV === "development", }); module.exports = nextConfig; diff --git a/src/app/[locale]/(main)/add-tournament/Map.tsx b/src/app/[locale]/(main)/add-tournament/Map.tsx index 1c24eaa..67cf849 100644 --- a/src/app/[locale]/(main)/add-tournament/Map.tsx +++ b/src/app/[locale]/(main)/add-tournament/Map.tsx @@ -11,7 +11,7 @@ import { useFormContext, useWatch } from "react-hook-form"; import { MapContainer, Marker, TileLayer } from "react-leaflet"; import { mapBoundsAtom } from "@/atoms"; -import MapEvents from "@/components/MapEvents"; +import { MapEvents } from "@/components/MapEvents"; const center: LatLngLiteral = { lat: 47.0844, lng: 2.3964 }; diff --git a/src/app/[locale]/(main)/add-tournament/TournamentForm.tsx b/src/app/[locale]/(main)/add-tournament/TournamentForm.tsx index d1f4fff..51e2b9c 100644 --- a/src/app/[locale]/(main)/add-tournament/TournamentForm.tsx +++ b/src/app/[locale]/(main)/add-tournament/TournamentForm.tsx @@ -59,7 +59,7 @@ const TournamentForm = () => { } catch (err: unknown) { console.log(err); setResponseMessage({ - isSuccessful: true, + isSuccessful: false, message: t("failure"), }); diff --git a/src/app/[locale]/(main)/auth/sign-in/page.tsx b/src/app/[locale]/(main)/auth/sign-in/page.tsx index a09e4e7..af151b0 100644 --- a/src/app/[locale]/(main)/auth/sign-in/page.tsx +++ b/src/app/[locale]/(main)/auth/sign-in/page.tsx @@ -53,7 +53,7 @@ export default function SignIn() { console.log(err); setResponseMessage({ - isSuccessful: true, + isSuccessful: false, message: t("failure"), }); diff --git a/src/app/[locale]/(main)/contact-us/ContactForm.tsx b/src/app/[locale]/(main)/contact-us/ContactForm.tsx index 376b725..963abe9 100644 --- a/src/app/[locale]/(main)/contact-us/ContactForm.tsx +++ b/src/app/[locale]/(main)/contact-us/ContactForm.tsx @@ -41,7 +41,7 @@ const ContactForm = () => { } catch (err: unknown) { console.log(err); setResponseMessage({ - isSuccessful: true, + isSuccessful: false, message: t("failure"), }); diff --git a/src/app/[locale]/(main)/zones/components/ZoneForm.tsx b/src/app/[locale]/(main)/zones/components/ZoneForm.tsx index 3a8160d..8625fba 100644 --- a/src/app/[locale]/(main)/zones/components/ZoneForm.tsx +++ b/src/app/[locale]/(main)/zones/components/ZoneForm.tsx @@ -12,9 +12,21 @@ import { ZoneEditorField } from "@/components/form/ZoneEditorField"; import { zoneSchema } from "@/schemas"; import { TimeControl } from "@/types"; -type ZoneFormValues = z.infer; +export type ZoneFormValues = z.infer; -export const ZoneForm = () => { +type ZoneFormProps = { + onSubmit: (data: ZoneFormValues) => Promise; + onCancel: () => void; + submitTitle?: string; + cancelTitle?: string; +}; + +export const ZoneForm = ({ + onSubmit, + onCancel, + submitTitle, + cancelTitle, +}: ZoneFormProps) => { const t = useTranslations("Zones"); const at = useTranslations("App"); const form = useForm({ @@ -30,67 +42,67 @@ export const ZoneForm = () => { }, }); - const onSubmit = async (data: ZoneFormValues) => { - try { - console.log(data); - } catch (err: unknown) { - console.log(err); - } - }; - return ( -
- -
-
-
- -
+ + +
+
+ +
-
-
- -
- {t("notificationsInfo")} -
+
+
+ +
+ {t("notificationsInfo")}
- - - - - -
- -
+ + + + + +
+ +
+
+ +
+ + - - -
+
+ + ); }; diff --git a/src/app/[locale]/(main)/zones/components/ZoneThumbnail.tsx b/src/app/[locale]/(main)/zones/components/ZoneThumbnail.tsx new file mode 100644 index 0000000..a893f4e --- /dev/null +++ b/src/app/[locale]/(main)/zones/components/ZoneThumbnail.tsx @@ -0,0 +1,41 @@ +import * as React from "react"; + +import L, { LatLngLiteral } from "leaflet"; +import "leaflet-defaulticon-compatibility"; +import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css"; +import "leaflet/dist/leaflet.css"; +import { FeatureGroup, GeoJSON, MapContainer, TileLayer } from "react-leaflet"; + +import { MapEvents } from "@/components/MapEvents"; +import type { ZoneModel } from "@/server/models/zoneModel"; + +const center: LatLngLiteral = { lat: 47.0844, lng: 2.3964 }; + +export type ZoneThumbnailProps = { + features: ZoneModel["features"]; + size: number; +}; + +export const ZoneThumbnail = ({ features, size }: ZoneThumbnailProps) => { + return ( + + + + + + + ); +}; + +export default ZoneThumbnail; diff --git a/src/app/[locale]/(main)/zones/create/page.tsx b/src/app/[locale]/(main)/zones/create/page.tsx index 3d318d9..38446f6 100644 --- a/src/app/[locale]/(main)/zones/create/page.tsx +++ b/src/app/[locale]/(main)/zones/create/page.tsx @@ -1,28 +1,50 @@ "use client"; -import { FeatureCollection } from "geojson"; +import { useState } from "react"; + import { useTranslations } from "next-intl"; -import { ZoneForm } from "../components/ZoneForm"; +import InfoMessage, { clearMessage } from "@/components/InfoMessage"; +import { createZone } from "@/server/createZone"; +import { useRouter } from "@/utils/navigation"; -type ZoneFormValues = { - features: FeatureCollection; -}; +import { ZoneForm, ZoneFormValues } from "../components/ZoneForm"; const CreateZone = () => { const t = useTranslations("Zones"); + const router = useRouter(); + + const [responseMessage, setResponseMessage] = useState({ + isSuccessful: false, + message: "", + }); const onSubmit = async (data: ZoneFormValues) => { try { - console.log(data); + await createZone(data); + router.push("/zones"); } catch (err: unknown) { console.log(err); + setResponseMessage({ + isSuccessful: false, + message: t("createFailure"), + }); + + clearMessage(setResponseMessage); } }; return ( -
- +
+

+ {t("createTitle")} +

+ + router.push("/zones")} /> +
); }; diff --git a/src/app/[locale]/(main)/zones/page.tsx b/src/app/[locale]/(main)/zones/page.tsx new file mode 100644 index 0000000..3ab5ed2 --- /dev/null +++ b/src/app/[locale]/(main)/zones/page.tsx @@ -0,0 +1,120 @@ +"use client"; + +import React from "react"; + +import { useQuery } from "@tanstack/react-query"; +import { useFormatter, useTranslations } from "next-intl"; +import dynamic from "next/dynamic"; +import { IoAdd } from "react-icons/io5"; + +import { Spinner } from "@/components/Spinner"; +import { myZones } from "@/server/myZones"; +import { TimeControl } from "@/types"; +import { Link } from "@/utils/navigation"; + +const ZoneThumbnail = dynamic(() => import("./components/ZoneThumbnail"), { + ssr: false, + loading: () =>
, +}); + +const Zones = () => { + const t = useTranslations("Zones"); + const at = useTranslations("App"); + const format = useFormatter(); + + const { data, isFetching, error } = useQuery({ + queryKey: ["zones"], + queryFn: async () => myZones(), + refetchOnWindowFocus: false, + staleTime: Infinity, + gcTime: 10 * 60 * 1000, + retry: false, + }); + + const zones = data?.data ?? []; + + return ( +
+

+ {t("title")} +

+

+ {t("info")} +

+ + {isFetching ? ( +
+ +
+ ) : ( +
+ {zones?.map((zone) => { + const notifications = [ + ...(zone.classicNotifications ? [TimeControl.Classic] : []), + ...(zone.rapidNotifications ? [TimeControl.Rapid] : []), + ...(zone.blitzNotifications ? [TimeControl.Blitz] : []), + ]; + console.log(notifications); + const notificationsList = format.list( + notifications.map((tc) => at("timeControlEnumInline", { tc })), + { type: "conjunction" }, + ); + + return ( +
+ +
+

+ {zone.name} +

+ +
+ {notifications.length === 0 + ? t("noNotifications") + : t.rich("withNotifications", { + list: notificationsList, + b: (str) => {str}, + })} +
+ +
+ + +
+
+
+ ); + })} +
+ )} + +
+ + + {t("addZoneButton")} + +
+
+ ); +}; + +export default Zones; diff --git a/src/components/Map.tsx b/src/components/Map.tsx index 41b2558..6544cca 100644 --- a/src/components/Map.tsx +++ b/src/components/Map.tsx @@ -19,7 +19,7 @@ import { LayerGroup, MapContainer, TileLayer } from "react-leaflet"; import MarkerClusterGroup from "react-leaflet-cluster"; import { debouncedHoveredListIdAtom, mapBoundsAtom } from "@/atoms"; -import MapEvents from "@/components/MapEvents"; +import { MapEvents } from "@/components/MapEvents"; export type MarkerRef = { getMarker: () => L.Marker; @@ -235,7 +235,7 @@ export const Map = ({ } }} > - + { +const worldBounds = L.latLngBounds(L.latLng(-90, -180), L.latLng(90, 180)); +const franceBounds = L.latLngBounds( + L.latLng(42.08, -5.12), + L.latLng(51.17, 9.53), +); + +type MapEventsProps = { + bounds?: L.LatLngBounds; + updateMapBoundsAtom?: boolean; +}; + +export const MapEvents = ({ + bounds = franceBounds, + updateMapBoundsAtom = false, +}: MapEventsProps) => { const locale = useLocale(); const t = useTranslations("Map"); const map = useMap(); const setMapBounds = useSetAtom(mapBoundsAtom); const [isPending, startTransition] = useTransition(); - const worldBounds = L.latLngBounds(L.latLng(-90, -180), L.latLng(90, 180)); - const franceBounds = L.latLngBounds( - L.latLng(42.08, -5.12), - L.latLng(51.17, 9.53), - ); + const map = useMapEvent("moveend", () => { + if (!updateMapBoundsAtom) return; useMapEvent("moveend", () => { // Set the map bounds atoms when the user pans/zooms @@ -35,7 +46,8 @@ const MapEvents = () => { // Viewport agnostic centering of France & max world bounds useEffect(() => { - map.setView(franceBounds.getCenter(), map.getBoundsZoom(franceBounds)); + const zoom = map.getBoundsZoom(bounds); + map.setView(bounds.getCenter(), zoom === Infinity ? 10 : zoom); map.setMaxBounds(worldBounds); map.options.maxBoundsViscosity = 1.0; // Prevents going past bounds while dragging @@ -65,5 +77,3 @@ const MapEvents = () => { return null; }; - -export default MapEvents; diff --git a/src/components/form/ZoneEditor.tsx b/src/components/form/ZoneEditor.tsx index 2c6cf2a..7badd04 100644 --- a/src/components/form/ZoneEditor.tsx +++ b/src/components/form/ZoneEditor.tsx @@ -6,15 +6,10 @@ import "leaflet-defaulticon-compatibility"; import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css"; import "leaflet-draw/dist/leaflet.draw.css"; import "leaflet/dist/leaflet.css"; -import { - FeatureGroup, - MapContainer, - TileLayer, - ZoomControl, -} from "react-leaflet"; +import { FeatureGroup, MapContainer, TileLayer } from "react-leaflet"; import { EditControl } from "react-leaflet-draw"; -import MapEvents from "../MapEvents"; +import { MapEvents } from "@/components/MapEvents"; const center: LatLngLiteral = { lat: 47.0844, lng: 2.3964 }; @@ -73,7 +68,7 @@ export const ZoneEditor = ({ value, onChange }: ZoneEditorProps) => { onDeleted={handleChange} draw={{ rectangle: false, - circle: true, + circle: false, polyline: false, polygon: true, marker: false, diff --git a/src/messages/en.json b/src/messages/en.json index 1c861c5..1e1525a 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -13,7 +13,11 @@ "selectPlaceholder": "Select...", "searchPlaceholder": "Search...", "noOptionsMessage": "No options found", - "timeControlEnum": "{tc, select, Classic {Classic} Rapid {Rapid} Blitz {Blitz} Other {Other} other {{tc}}}" + "timeControlEnum": "{tc, select, Classic {Classic} Rapid {Rapid} Blitz {Blitz} Other {Other} other {{tc}}}", + "timeControlEnumInline": "{tc, select, Classic {classic} Rapid {rapid} Blitz {blitz} Other {other} other {{tc}}}", + + "saveButton": "Save", + "cancelButton": "Cancel" }, "Errors": { @@ -230,10 +234,22 @@ }, "Zones": { + "title": "Your Zones", + "info": "You can set up zones to receive email notifications for tournaments in these areas. These zones can also be used to filter tournaments on the main map.", + "noNotifications": "You have not enabled email notifications for this zone.", + "withNotifications": "You will receive email notifications for new tournaments with {list} time control in this region.", + "editButton": "Edit", + "deleteButton": "Delete", + "addZoneButton": "Add a Region", + + "createTitle": "Create a new zone", + "zoneNameLabel": "Zone name", "zoneNamePlaceholder": "Name for your zone", "notificationsLabel": "Notifications", "notificationsInfo": "You can choose to turn on notifications for the time controls that interest you, and will receive an email when a new tournament is added to this zone.", - "saveButton": "Save" + "saveButton": "Save", + + "createFailure": "Oops, something went wrong. Please try again later." } } diff --git a/src/messages/fr.json b/src/messages/fr.json index 70ad96a..b6ac0dc 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -13,7 +13,11 @@ "selectPlaceholder": "Choisir...", "searchPlaceholder": "Rechercher...", "noOptionsMessage": "Pas d'options", - "timeControlEnum": "{tc, select, Classic {Cadence Lente} Rapid {Rapide} Blitz {Blitz} Other {Autres} other {{tc}}}" + "timeControlEnum": "{tc, select, Classic {Cadence Lente} Rapid {Rapide} Blitz {Blitz} Other {Autres} other {{tc}}}", + "timeControlEnumInline": "{tc, select, Classic {lente} Rapid {rapide} Blitz {blitz} Other {autres} other {{tc}}}", + + "saveButton": "Sauvegarder", + "cancelButton": "Annuler" }, "Errors": { @@ -231,10 +235,22 @@ }, "Zones": { - "zoneNameLabel": "Nom de la zone", - "zoneNamePlaceholder": "Votre nom pour la zone", + "title": "Vos régions", + "info": "Vous pouvez définir des zones personnalisées pour recevoir des notifications par e-mail pour les tournois dans ces zones. Ces zones peuvent aussi être utiliser pour filtrer les tournois sur la carte.", + "noNotifications": "Vous n'avez pas activé les notifications par email pour cette région.", + "withNotifications": "Vous allez recevoir des notifications par e-mail pour les nouveaux tournois de cadences {list} dans cette région.", + "editButton": "Modifier", + "deleteButton": "Supprimer", + "addZoneButton": "Ajouter une région", + + "createTitle": "Créer une nouvelle région", + + "zoneNameLabel": "Nom de la région", + "zoneNamePlaceholder": "Votre nom pour cette région", "notificationsLabel": "Notifications", "notificationsInfo": "Vous pouvez choisir d'activer les notifications pour les cadences qui vous intéressent, et vous recevrez un e-mail lorsqu'un nouveau tournoi est ajouté dans cette zone.", - "saveButton": "Sauvegarder" + "saveButton": "Sauvegarder", + + "createFailure": "Oups, quelque chose s'est mal passé. Veuillez réessayer." } } diff --git a/src/schemas.ts b/src/schemas.ts index 5a78cc0..bd892a5 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -1,3 +1,4 @@ +import { zu } from "@infra-blocks/zod-utils"; import { z } from "zod"; import { TimeControl } from "@/types"; @@ -30,3 +31,16 @@ export const addTournamentSchema = z.object({ export const fetchTournamentResultsSchema = z.object({ id: z.string().min(1, { message: "FormValidation.required" }), }); + +export const zoneSchema = z + .object({ + name: z.string().min(1, { message: "FormValidation.required" }), + features: zu.geojson.featureCollection(), + classicNotifications: z.boolean(), + rapidNotifications: z.boolean(), + blitzNotifications: z.boolean(), + }) + .refine((data) => data.features.features.length > 0, { + message: "FormValidation.zone", + path: ["features"], + }); diff --git a/src/server/createZone.ts b/src/server/createZone.ts new file mode 100644 index 0000000..a414f75 --- /dev/null +++ b/src/server/createZone.ts @@ -0,0 +1,34 @@ +"use server"; + +import { ObjectId } from "mongodb"; + +import { auth } from "@/auth"; +import { zoneSchema } from "@/schemas"; +import { collections, dbConnect } from "@/server/mongodb"; +import { errorLog } from "@/utils/logger"; + +import { ZoneModel } from "./models/zoneModel"; +import { action } from "./safeAction"; + +export const createZone = action(zoneSchema, async (input) => { + try { + await dbConnect(); + + const user = await auth(); + if (!user?.user) { + throw new Error("You must be logged in to create a zone"); + } + + const zoneData: ZoneModel = { + ...input, + userId: new ObjectId(user.user!.id!), + }; + + const result = await collections.zones!.insertOne(zoneData); + + return true; + } catch (error) { + errorLog(error); + throw error; + } +}); diff --git a/src/server/models/zoneModel.ts b/src/server/models/zoneModel.ts new file mode 100644 index 0000000..0b61875 --- /dev/null +++ b/src/server/models/zoneModel.ts @@ -0,0 +1,14 @@ +import { zu } from "@infra-blocks/zod-utils"; +import { ObjectId } from "mongodb"; +import { z } from "zod"; + +const featureCollection = zu.geojson.featureCollection(); + +export type ZoneModel = { + userId: ObjectId; + name: string; + classicNotifications: boolean; + rapidNotifications: boolean; + blitzNotifications: boolean; + features: z.infer; +}; diff --git a/src/server/mongodb.ts b/src/server/mongodb.ts index 4eccd15..1b9281a 100644 --- a/src/server/mongodb.ts +++ b/src/server/mongodb.ts @@ -10,6 +10,7 @@ export const collections: { tournaments?: mongoDB.Collection; clubs?: mongoDB.Collection; users?: mongoDB.Collection; + zones?: mongoDB.Collection; } = {}; if (!process.env.MONGODB_URI) { @@ -34,6 +35,7 @@ export async function dbConnect() { const userData: mongoDB.Db = client!.db("userData"); collections.users = userData.collection("userData"); + collections.zones = userData.collection("zones"); const tournamentData: mongoDB.Db = client!.db("tournamentsFranceDB"); collections.tournaments = tournamentData.collection("tournaments"); diff --git a/src/server/myZones.ts b/src/server/myZones.ts new file mode 100644 index 0000000..168ecd3 --- /dev/null +++ b/src/server/myZones.ts @@ -0,0 +1,29 @@ +"use server"; + +import { omit } from "lodash"; +import { ObjectId } from "mongodb"; +import { z } from "zod"; + +import { auth } from "@/auth"; +import { collections, dbConnect } from "@/server/mongodb"; + +import { action } from "./safeAction"; + +export const myZones = action(z.void(), async () => { + await dbConnect(); + + const user = await auth(); + if (!user?.user) { + throw new Error("You must be logged in to fetch your zones"); + } + + const zones = await collections + .zones!.find({ userId: new ObjectId(user.user!.id!) }) + .toArray(); + + return zones.map((zone) => ({ + ...omit(zone, ["_id"]), + id: zone._id.toString(), + userId: zone.userId.toString(), + })); +}); diff --git a/src/utils/navigation.ts b/src/utils/navigation.ts index a194710..74007c7 100644 --- a/src/utils/navigation.ts +++ b/src/utils/navigation.ts @@ -17,6 +17,16 @@ export const pathnames = { en: "/tournaments", }, + "/zones": { + fr: "/regions", + en: "/zones", + }, + + "/zones/create": { + fr: "/regions/creer", + en: "/zones/create", + }, + "/add-tournament": { fr: "/ajouter-un-tournoi", en: "/add-tournament", diff --git a/worker/index.js b/worker/index.js new file mode 100644 index 0000000..343628c --- /dev/null +++ b/worker/index.js @@ -0,0 +1,5 @@ +// To disable all workbox logging during development, you can set self.__WB_DISABLE_DEV_LOGS to true +// https://developers.google.com/web/tools/workbox/guides/configure-workbox#disable_logging + +// eslint-disable-next-line no-underscore-dangle,no-restricted-globals +self.__WB_DISABLE_DEV_LOGS = true;