diff --git a/package.json b/package.json index be150ec..dc72865 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@headlessui/react": "^1.7.17", "@headlessui/tailwindcss": "^0.2.0", "@hookform/resolvers": "^3.3.3", + "@infra-blocks/zod-utils": "^0.4.2", "@kodingdotninja/use-tailwind-breakpoint": "^0.0.5", "@next/bundle-analyzer": "^14.0.4", "@tanstack/react-query": "^5.29.0", @@ -23,10 +24,12 @@ "@types/node": "^20.8.4", "autoprefixer": "^10.4.16", "date-fns": "^2.30.0", + "geojson": "^0.5.0", "jotai": "^2.6.1", "leaflet": "^1.9.4", "leaflet-defaulticon-compatibility": "^0.1.2", "leaflet-gesture-handling": "^1.2.2", + "leaflet-draw": "^1.0.4", "leaflet.markercluster": "^1.5.3", "leaflet.smooth_marker_bouncing": "^3.0.3", "lodash": "^4.17.21", @@ -47,6 +50,7 @@ "react-input-mask": "^2.0.4", "react-leaflet": "^4.2.1", "react-leaflet-cluster": "^2.1.0", + "react-leaflet-draw": "^0.20.4", "react-select": "^5.7.4", "react-tooltip": "^5.18.0", "sharp": "^0.33.0", diff --git a/src/app/[locale]/(main)/zones/components/ZoneForm.tsx b/src/app/[locale]/(main)/zones/components/ZoneForm.tsx new file mode 100644 index 0000000..3a8160d --- /dev/null +++ b/src/app/[locale]/(main)/zones/components/ZoneForm.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslations } from "next-intl"; +import { FormProvider, useForm } from "react-hook-form"; +import { z } from "zod"; + +import { InlineSwitchField } from "@/components/form/InlineSwitchField"; +import { Label } from "@/components/form/Label"; +import { TextField } from "@/components/form/TextField"; +import { ZoneEditorField } from "@/components/form/ZoneEditorField"; +import { zoneSchema } from "@/schemas"; +import { TimeControl } from "@/types"; + +type ZoneFormValues = z.infer; + +export const ZoneForm = () => { + const t = useTranslations("Zones"); + const at = useTranslations("App"); + const form = useForm({ + resolver: zodResolver(zoneSchema), + defaultValues: { + classicNotifications: false, + rapidNotifications: false, + blitzNotifications: false, + features: { + type: "FeatureCollection", + features: [], + }, + }, + }); + + const onSubmit = async (data: ZoneFormValues) => { + try { + console.log(data); + } catch (err: unknown) { + console.log(err); + } + }; + + return ( +
+ +
+
+
+ +
+ +
+
+ +
+ {t("notificationsInfo")} +
+
+
+ + + + + +
+ +
+
+ +
+
+
+ ); +}; diff --git a/src/app/[locale]/(main)/zones/create/page.tsx b/src/app/[locale]/(main)/zones/create/page.tsx new file mode 100644 index 0000000..3d318d9 --- /dev/null +++ b/src/app/[locale]/(main)/zones/create/page.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { FeatureCollection } from "geojson"; +import { useTranslations } from "next-intl"; + +import { ZoneForm } from "../components/ZoneForm"; + +type ZoneFormValues = { + features: FeatureCollection; +}; + +const CreateZone = () => { + const t = useTranslations("Zones"); + + const onSubmit = async (data: ZoneFormValues) => { + try { + console.log(data); + } catch (err: unknown) { + console.log(err); + } + }; + + return ( +
+ +
+ ); +}; + +export default CreateZone; diff --git a/src/components/form/InlineSwitch.tsx b/src/components/form/InlineSwitch.tsx new file mode 100644 index 0000000..833eb76 --- /dev/null +++ b/src/components/form/InlineSwitch.tsx @@ -0,0 +1,47 @@ +import React from "react"; + +import { Switch, SwitchProps } from "@headlessui/react"; +import { twMerge } from "tailwind-merge"; + +export const InlineSwitch = ( + props: { + label?: React.ReactNode; + tooltip?: string; + tooltipId?: string; + } & SwitchProps<"button">, +) => { + const { + label, + tooltip, + tooltipId, + disabled, + + ...rest + } = props; + + return ( + +
+ + + + {label && ( + <> + +
{label}
+
+ + )} +
+
+ ); +}; diff --git a/src/components/form/InlineSwitchField.tsx b/src/components/form/InlineSwitchField.tsx new file mode 100644 index 0000000..7559157 --- /dev/null +++ b/src/components/form/InlineSwitchField.tsx @@ -0,0 +1,66 @@ +import React from "react"; + +import { SwitchProps } from "@headlessui/react"; +import { Controller, FieldPath, FieldValues } from "react-hook-form"; +import { twMerge } from "tailwind-merge"; + +import { Field, GenericFieldProps } from "@/components/form/Field"; + +import { InlineSwitch } from "./InlineSwitch"; + +type InlineSwitchFieldProps< + TFieldValues extends FieldValues = FieldValues, + TFieldName extends FieldPath = FieldPath, +> = GenericFieldProps & + Omit, "name">; + +export const InlineSwitchField = < + TFieldValues extends FieldValues = FieldValues, + TFieldName extends FieldPath = FieldPath, +>( + props: InlineSwitchFieldProps, +) => { + const { + name, + control, + label, + disabled, + className, + labelClassName, + childrenWrapperClassName, + hideErrorMessage, + + ...rest + } = props; + + return ( + +
+ ( + + )} + /> +
+
+ ); +}; diff --git a/src/components/form/ZoneEditor.tsx b/src/components/form/ZoneEditor.tsx new file mode 100644 index 0000000..2c6cf2a --- /dev/null +++ b/src/components/form/ZoneEditor.tsx @@ -0,0 +1,88 @@ +import * as React from "react"; + +import type { FeatureCollection } from "geojson"; +import L, { LatLngLiteral } from "leaflet"; +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 { EditControl } from "react-leaflet-draw"; + +import MapEvents from "../MapEvents"; + +const center: LatLngLiteral = { lat: 47.0844, lng: 2.3964 }; + +export type ZoneEditorProps = { + value: FeatureCollection; + onChange: (geoJson: FeatureCollection) => void; +}; + +export const ZoneEditor = ({ value, onChange }: ZoneEditorProps) => { + const ref = React.useRef(null); + + React.useEffect(() => { + if (ref.current?.getLayers().length === 0 && value) { + L.geoJSON(value).eachLayer((layer) => { + if ( + layer instanceof L.Polyline || + layer instanceof L.Polygon || + layer instanceof L.Marker + ) { + if (layer?.feature?.properties.radius && ref.current) { + new L.Circle(layer.feature.geometry.coordinates.slice().reverse(), { + radius: layer.feature?.properties.radius, + }).addTo(ref.current); + } else { + ref.current?.addLayer(layer); + } + } + }); + } + }, [value]); + + const handleChange = () => { + const geoJson = ref.current?.toGeoJSON(); + if (geoJson?.type === "FeatureCollection") { + onChange(geoJson); + } + }; + + return ( + + + + + + + + + ); +}; + +export default ZoneEditor; diff --git a/src/components/form/ZoneEditorField.tsx b/src/components/form/ZoneEditorField.tsx new file mode 100644 index 0000000..8e297be --- /dev/null +++ b/src/components/form/ZoneEditorField.tsx @@ -0,0 +1,77 @@ +import React from "react"; + +import { FeatureCollection } from "geojson"; +import dynamic from "next/dynamic"; +import { + Controller, + FieldPath, + FieldValues, + useFormContext, +} from "react-hook-form"; + +import { Prettify } from "@/types"; + +import LoadingMap from "../LoadingMap"; + +import { Field, GenericFieldProps } from "./Field"; +import type { ZoneEditorProps } from "./ZoneEditor"; + +const ZoneEditor = dynamic(() => import("./ZoneEditor"), { + ssr: false, + loading: LoadingMap, +}); + +type ZoneEditorFieldProps< + TFieldValues extends FieldValues = FieldValues, + TFieldName extends FieldPath = FieldPath, +> = Prettify< + GenericFieldProps & + Omit +>; + +export const ZoneEditorField = < + TFieldValues extends FieldValues = FieldValues, + TFieldName extends FieldPath = FieldPath, +>( + props: ZoneEditorFieldProps, +) => { + const { + name, + control, + label, + className, + labelClassName, + + childrenWrapperClassName, + hideErrorMessage, + + ...rest + } = props; + + const form = useFormContext(); + + return ( + +
+ ( + onChange(geoJson)} + /> + )} + /> +
+
+ ); +}; diff --git a/src/css/globals.css b/src/css/globals.css index 1e04eb2..780c95b 100644 --- a/src/css/globals.css +++ b/src/css/globals.css @@ -101,9 +101,15 @@ input[type="number"] { position: absolute; width: 0px; } + .react-datepicker-popper[data-placement^="bottom"] { padding-top: 10px; } + .no-calendar::-webkit-calendar-picker-indicator { display: none; } + +.sr-only { + display: none; +} diff --git a/src/messages/en.json b/src/messages/en.json index 52808b8..1c861c5 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -39,7 +39,8 @@ "FormValidation": { "required": "Required", - "url": "Invalid URL" + "url": "Invalid URL", + "zone": "Please draw at least one region on the map" }, "Home": { @@ -221,10 +222,18 @@ "checkEmail": "Please check your email for a sign-in link.", "email": { - "subject": "Sign in to ${host}", + "subject": "Sign in to {host}", "button": "Sign in", "ignore": "If you didn't request this, you can safely ignore this email.", - "textMessage": "Click the following link to sign in to ${host}: {url}\n\n" + "textMessage": "Click the following link to sign in to {host}: {url}\n\n" } + }, + + "Zones": { + "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" } } diff --git a/src/messages/fr.json b/src/messages/fr.json index 25d84a0..70ad96a 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -39,7 +39,8 @@ "FormValidation": { "required": "Requis", - "url": "L'URL n'est pas valide" + "url": "L'URL n'est pas valide", + "zone": "Veuillez dessiner au moins une région sur la carte" }, "Home": { @@ -222,10 +223,18 @@ "checkEmail": "Veuillez vérifier votre e-mail pour un lien de connexion.", "email": { - "subject": "Connexion à ${host}", + "subject": "Connexion à {host}", "button": "Se connecter", "ignore": "Si vous n'avez pas demandé cela, vous pouvez ignorer en toute sécurité cet e-mail.", - "textMessage": "Cliquez sur le lien suivant pour vous connecter à ${host}: {url}\n\n" + "textMessage": "Cliquez sur le lien suivant pour vous connecter à {host}: {url}\n\n" } + }, + + "Zones": { + "zoneNameLabel": "Nom de la zone", + "zoneNamePlaceholder": "Votre nom pour la zone", + "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" } } diff --git a/src/types.ts b/src/types.ts index acb2168..225134c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,42 +1,10 @@ import { LatLngLiteral } from "leaflet"; -import { ObjectId } from "mongodb"; import { SafeAction } from "next-safe-action"; -import { z } from "zod"; + +import { TournamentModel } from "./server/models/tournamentModel"; export type Status = "scheduled" | "ongoing" | "finished" | "in-play"; -export const tournamentDataSchema = z.object({ - tournament_id: z.string(), - town: z.string(), - department: z.string(), - country: z.string(), - tournament: z.string(), - url: z.string(), - time_control: z.string(), - norm_tournament: z.boolean().optional(), - date: z.string(), - start_date: z.string(), - end_date: z.string(), - coordinates: z.array(z.number()).min(2).max(2), - entry_method: z.enum(["manual", "auto"]), - pending: z.boolean().optional(), - status: z.enum(["scheduled", "ongoing", "finished", "in-play"]), -}); - -export type TournamentData = z.infer & { - _id: ObjectId; -}; - -export type ClubData = { - _id: ObjectId; - name: string; - url?: string; - address?: string; - email?: string; - website?: string; - coordinates: [number, number]; -}; - export enum TimeControl { Classic = "Classic", Rapid = "Rapid", @@ -60,7 +28,7 @@ export type Tournament = { status: Status; }; -export const tcMap: Record = { +export const tcMap: Record = { "Cadence Lente": TimeControl.Classic, Rapide: TimeControl.Rapid, Blitz: TimeControl.Blitz, diff --git a/tsconfig.json b/tsconfig.json index 13f4e45..967d6a4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,7 @@ "noEmit": true, "esModuleInterop": true, "module": "esnext", - "moduleResolution": "node", + "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve",