diff --git a/.vscode/settings.json b/.vscode/settings.json index 89c27a5..c2a5091 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,12 +5,15 @@ "colour", "colours", "contactez", + "datepicker", "defaulticon", "Depfu", "Échecs", "Fédération", "Florifourchette", "Française", + "headlessui", + "hookform", "kodingdotninja", "Lente", "localisation", @@ -25,6 +28,7 @@ "spiderified", "superjson", "tailwindcss", + "tanstack", "timothyarmes", "tournois", "trivago", @@ -33,5 +37,12 @@ "unspiderfy" ], - "css.customData": [".vscode/tailwindcss.json"] + "css.customData": [".vscode/tailwindcss.json"], + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "css.lint.unknownAtRules": "ignore", + "tailwindCSS.experimental.classRegex": ["[a-zA-Z]*ClassName=\"([^\"]+)\""] } diff --git a/app/[lang]/ajouter-un-tournoi/Map.tsx b/app/[lang]/ajouter-un-tournoi/Map.tsx index b6937e0..f192a13 100644 --- a/app/[lang]/ajouter-un-tournoi/Map.tsx +++ b/app/[lang]/ajouter-un-tournoi/Map.tsx @@ -1,111 +1,73 @@ "use client"; -import { ChangeEvent, useMemo, useRef } from "react"; +import { useMemo, useRef } from "react"; import { useSetAtom } from "jotai"; -import L from "leaflet"; +import L, { LatLngLiteral } from "leaflet"; import "leaflet-defaulticon-compatibility"; import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css"; import "leaflet/dist/leaflet.css"; -import { useTranslations } from "next-intl"; +import { useFormContext, useWatch } from "react-hook-form"; import { MapContainer, Marker, TileLayer } from "react-leaflet"; import MapEvents from "@/app/[lang]/components/MapEvents"; import { mapBoundsAtom } from "@/app/atoms"; -import { MapProps } from "@/types"; -const Map = ({ position, setPosition, center }: MapProps) => { - const t = useTranslations("Map"); +const center: LatLngLiteral = { lat: 47.0844, lng: 2.3964 }; - const latValue = position.lat.toFixed(4); - const lngValue = position.lng.toFixed(4); +const Map = () => { + const { control, setValue } = useFormContext(); + + const latValue = useWatch({ control, name: "tournament.coordinates.0" }); + const lngValue = useWatch({ control, name: "tournament.coordinates.1" }); const setMapBounds = useSetAtom(mapBoundsAtom); const markerRef = useRef(null); - const handleLatChange = ({ target }: ChangeEvent) => { - const newLat = Number(target.value); - setPosition((prevPosition) => ({ ...prevPosition, lat: newLat })); - }; - - const handleLngChange = ({ target }: ChangeEvent) => { - const newLng = Number(target.value); - setPosition((prevPosition) => ({ ...prevPosition, lng: newLng })); - }; - const eventHandlers = useMemo( () => ({ dragend() { const marker = markerRef.current; if (marker != null) { - setPosition(marker.getLatLng()); + const position = marker.getLatLng(); + setValue( + "tournament.coordinates.0", + Math.round((position.lat + Number.EPSILON) * 10000) / 10000, + ); + setValue( + "tournament.coordinates.1", + Math.round((position.lng + Number.EPSILON) * 10000) / 10000, + ); } }, }), - [setPosition], + [setValue], ); return ( - <> -
- - -
-
- - -
- -
- { - if (map) { - setMapBounds(map.getBounds()); - } - }} - > - - - - -
- + { + if (map) { + setMapBounds(map.getBounds()); + } + }} + > + + + + ); }; diff --git a/app/[lang]/ajouter-un-tournoi/TournamentForm.tsx b/app/[lang]/ajouter-un-tournoi/TournamentForm.tsx index 6aa0f52..94e35e9 100644 --- a/app/[lang]/ajouter-un-tournoi/TournamentForm.tsx +++ b/app/[lang]/ajouter-un-tournoi/TournamentForm.tsx @@ -2,18 +2,25 @@ import { useState } from "react"; -import { useAtomValue } from "jotai"; +import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslations } from "next-intl"; import dynamic from "next/dynamic"; +import { FormProvider, useForm } from "react-hook-form"; +import { z } from "zod"; +import { clearMessage } from "@/app/[lang]/components/InfoMessage"; import InfoMessage from "@/app/[lang]/components/InfoMessage"; import LoadingMap from "@/app/[lang]/components/LoadingMap"; -import { franceCenterAtom } from "@/app/atoms"; -import { - handleClearTournamentForm, - handleTournamentSubmit, -} from "@/handlers/formHandlers"; -import useTournamentForm from "@/hooks/useTournamentForm"; +import { DateField } from "@/app/[lang]/components/form/DateField"; +import { SelectField } from "@/app/[lang]/components/form/SelectField"; +import { SwitchField } from "@/app/[lang]/components/form/SwitchField"; +import { TextAreaField } from "@/app/[lang]/components/form/TextAreaField"; +import { TextField } from "@/app/[lang]/components/form/TextField"; +import { addTournamentSchema } from "@/schemas"; +import { TimeControl } from "@/types"; +import { trpc } from "@/utils/trpc"; + +type TournamentFormValues = z.infer; const Map = dynamic(() => import("./Map"), { ssr: false, @@ -22,248 +29,184 @@ const Map = dynamic(() => import("./Map"), { const TournamentForm = () => { const t = useTranslations("AddTournament"); - const center = useAtomValue(franceCenterAtom); - const formRefs = useTournamentForm(); - - const [isSending, setIsSending] = useState(false); + const at = useTranslations("App"); + const addTournament = trpc.addTournament.useMutation(); const [responseMessage, setResponseMessage] = useState({ isSuccessful: false, message: "", }); + const form = useForm({ + resolver: zodResolver(addTournamentSchema), + defaultValues: { + tournament: { + norm_tournament: false, + coordinates: [47.0844, 2.3964], + }, + }, + }); + + const onSubmit = async (data: TournamentFormValues) => { + try { + await addTournament.mutateAsync(data); + setResponseMessage({ + isSuccessful: true, + message: t("success"), + }); + + clearMessage(setResponseMessage); + form.reset(); + } catch (err: unknown) { + console.log(err); + setResponseMessage({ + isSuccessful: true, + message: t("failure"), + }); + + clearMessage(setResponseMessage); + } + }; + return ( - <> -
- handleTournamentSubmit( - e, - t, - setIsSending, - setResponseMessage, - formRefs, - ) - } - className="space-y-8" - > + +
- -
- - +
- -
- - + ({ + value: tc, + label: at("timeControlEnum", { tc }), + }))} + required + />
- - +
- -
- -
- -
- -
- -
- -
- - + />
- + +
+ +
+
+ +
+ +
+ +
- ; + + - +
); }; diff --git a/app/[lang]/ajouter-un-tournoi/page.tsx b/app/[lang]/ajouter-un-tournoi/page.tsx index 9983cdd..154830a 100644 --- a/app/[lang]/ajouter-un-tournoi/page.tsx +++ b/app/[lang]/ajouter-un-tournoi/page.tsx @@ -14,9 +14,11 @@ export default function Contact() { > {t("title")} +

{t("info")}

+ diff --git a/app/[lang]/components/ThemeSwitcher.tsx b/app/[lang]/components/ThemeSwitcher.tsx index e52b54c..497ca19 100644 --- a/app/[lang]/components/ThemeSwitcher.tsx +++ b/app/[lang]/components/ThemeSwitcher.tsx @@ -25,7 +25,7 @@ const ThemeSwitcher = () => { diff --git a/app/[lang]/components/form/DateField/components/DatePickerCustomHeader.tsx b/app/[lang]/components/form/DateField/components/DatePickerCustomHeader.tsx new file mode 100644 index 0000000..3cc7e32 --- /dev/null +++ b/app/[lang]/components/form/DateField/components/DatePickerCustomHeader.tsx @@ -0,0 +1,54 @@ +import React from "react"; + +import { format } from "date-fns"; +import { enGB, fr } from "date-fns/locale"; +import { useLocale } from "next-intl"; +import { ReactDatePickerCustomHeaderProps } from "react-datepicker"; +import { IoChevronBack, IoChevronForward } from "react-icons/io5"; +import { twMerge } from "tailwind-merge"; + +const chevronClasses = { + chevronRoot: "h-4 w-4 dark:text-white", + chevronClassDisabled: "!text-neutral-500", +}; + +export const DatePickerCustomHeader = ({ + date, + decreaseMonth, + increaseMonth, + prevMonthButtonDisabled, + nextMonthButtonDisabled, +}: ReactDatePickerCustomHeaderProps) => { + // Bit of a hack. inputRef doesn't work because DatePicker doesn't correctly propagate props + const locale = useLocale(); + + return ( +
+ + {format(date, "LLLL yyyy", { locale: locale === "fr" ? fr : enGB })} + +
+ ); +}; diff --git a/app/[lang]/components/form/DateField/components/InputDatePicker.tsx b/app/[lang]/components/form/DateField/components/InputDatePicker.tsx new file mode 100644 index 0000000..362b6db --- /dev/null +++ b/app/[lang]/components/form/DateField/components/InputDatePicker.tsx @@ -0,0 +1,65 @@ +import React, { forwardRef } from "react"; + +import InputMask from "react-input-mask"; +import { twMerge } from "tailwind-merge"; + +interface InputProps { + error?: boolean; + className?: string; + inputContainerClass?: string; + inputClass?: string; + mask?: string | (string | RegExp)[]; +} + +type AllProps = React.DetailedHTMLProps< + React.InputHTMLAttributes, + HTMLInputElement +> & + InputProps; + +export const InputDatePicker = forwardRef( + ( + { + error, + className, + children, + inputContainerClass, + inputClass, + mask, + ...props + }, + inputRef, + ) => { + return ( +
+ { + if (e.code === "Enter") { + e.stopPropagation(); + } + }} + {...props} + /> +
+ ); + }, +); + +InputDatePicker.displayName = "InputDatePicker"; diff --git a/app/[lang]/components/form/DateField/components/index.ts b/app/[lang]/components/form/DateField/components/index.ts new file mode 100644 index 0000000..6fbbb75 --- /dev/null +++ b/app/[lang]/components/form/DateField/components/index.ts @@ -0,0 +1 @@ +export * from "./InputDatePicker"; diff --git a/app/[lang]/components/form/DateField/index.tsx b/app/[lang]/components/form/DateField/index.tsx new file mode 100644 index 0000000..27fe57d --- /dev/null +++ b/app/[lang]/components/form/DateField/index.tsx @@ -0,0 +1,129 @@ +import React, { useRef } from "react"; + +import { getYear, isValid, parse } from "date-fns"; +import fr from "date-fns/locale/fr"; +import { get, range } from "lodash"; +import { useLocale, useTranslations } from "next-intl"; +import DatePicker from "react-datepicker"; +import { registerLocale } from "react-datepicker"; +import { Controller, useFormContext } from "react-hook-form"; +import { twMerge } from "tailwind-merge"; + +import { Field, FieldProps } from "../Field"; + +import { InputDatePicker } from "./components"; +import { DatePickerCustomHeader } from "./components/DatePickerCustomHeader"; + +registerLocale("fr", fr); + +type DateFieldProps = FieldProps & { + maxDate?: Date; + minDate?: Date; + dateFormat?: string; + className?: string; + datePickerPopperClass?: string; + required?: boolean; +}; + +export const DateField = ({ + minDate, + maxDate, + dateFormat = "dd/MM/yyyy", + className, + datePickerPopperClass, + + name, + ...otherFieldProps +}: DateFieldProps) => { + const locale = useLocale(); + const at = useTranslations("App"); + const min = minDate ? minDate.getFullYear() : 1900; + + const inputRef = useRef(null); + + const form = useFormContext(); + const { + formState: { errors }, + } = form; + + const hasError = name && !!get(errors, name)?.message; + + return ( + +
+ ( + ( +
+ {day.slice(0, 3)} +
+ )} + weekDayClassName={() => + "text-xs h-8 w-8 flex-1 bg-gray-50 text-gray-900 dark:bg-gray-700 dark:text-white" + } + dayClassName={() => + "text-xs h-8 w-8 flex-1 bg-gray-50 cursor-pointer hover:font-bold text-gray-900 dark:bg-gray-700 dark:text-white" + } + value={field.value} + selected={field.value} + onSelect={field.onChange} + onChange={field.onChange} + onChangeRaw={(e) => { + // Called when the user types in the input + if (e.currentTarget.value) { + const value = e.currentTarget.value; + const date = parse(value, at("dateParseFormat"), new Date()); + if ( + isValid(date) && + date.getFullYear() >= min && + maxDate && + getYear(maxDate) >= getYear(date) + ) { + field.onChange(date); + } + } + }} + popperPlacement="bottom-start" + popperClassName={twMerge( + "z-50 mt-[12px] rounded border", + "border-gray-300 bg-gray-50 text-gray-900", + "dark:border-gray-600 dark:bg-gray-700 dark:text-white", + + datePickerPopperClass, + )} + showPopperArrow={false} + calendarClassName={twMerge( + "border-gray-300 bg-gray-50 text-gray-900", + "dark:border-gray-600 dark:bg-gray-700 dark:text-white", + )} + minDate={minDate} + showFullMonthYearPicker + maxDate={maxDate} + renderCustomHeader={(props) => ( + + )} + customInput={ + + } + /> + )} + /> +
+
+ ); +}; diff --git a/app/[lang]/components/form/ErrorMessage.tsx b/app/[lang]/components/form/ErrorMessage.tsx new file mode 100644 index 0000000..8fb0346 --- /dev/null +++ b/app/[lang]/components/form/ErrorMessage.tsx @@ -0,0 +1,21 @@ +import { useTranslations } from "next-intl"; + +type ErrorMessageProps = { + errorMessage: string; +}; + +export const ErrorMessage = ({ errorMessage }: ErrorMessageProps) => { + const t = useTranslations(); + + type TranslationKey = Parameters[0]; + + return errorMessage !== undefined ? ( +
+

+ {errorMessage.startsWith("FormValidation") + ? t(errorMessage as TranslationKey) + : errorMessage} +

+
+ ) : null; +}; diff --git a/app/[lang]/components/form/Field.tsx b/app/[lang]/components/form/Field.tsx new file mode 100644 index 0000000..09e8f2a --- /dev/null +++ b/app/[lang]/components/form/Field.tsx @@ -0,0 +1,68 @@ +import { ReactNode } from "react"; + +import { get } from "lodash"; +import { useFormContext } from "react-hook-form"; +import { twMerge } from "tailwind-merge"; + +import { ErrorMessage } from "./ErrorMessage"; +import { Label } from "./Label"; + +export type FieldProps = { + name: string; + className?: string; + labelClassName?: string; + childrenWrapperClassName?: string; + label?: React.ReactNode; + hideErrorMessage?: boolean; +}; + +export const Field = ( + props: Omit & { + required?: boolean; + name?: string; + children: ReactNode; + }, +) => { + const { + name, + className, + labelClassName, + childrenWrapperClassName, + label, + children, + required, + hideErrorMessage = false, + } = props; + + const form = useFormContext(); + + const { + formState: { errors }, + } = form; + + const hasError = name && !!get(errors, name)?.message; + + return ( +
+ {label ? ( + + ) : null} + +
+ {children} +
+ + {hideErrorMessage || !hasError ? null : ( + + )} +
+ ); +}; diff --git a/app/[lang]/components/form/Label.tsx b/app/[lang]/components/form/Label.tsx new file mode 100644 index 0000000..e7ac14b --- /dev/null +++ b/app/[lang]/components/form/Label.tsx @@ -0,0 +1,31 @@ +import { twMerge } from "tailwind-merge"; + +type LabelProps = React.DetailedHTMLProps< + React.LabelHTMLAttributes, + HTMLLabelElement +> & { + required?: boolean; + className?: string; + tooltip?: React.ReactNode; +}; + +export const Label = ({ + required, + className, + children, + tooltip, + ...labelProps +}: LabelProps) => { + return ( + + ); +}; diff --git a/app/[lang]/components/form/SelectField.tsx b/app/[lang]/components/form/SelectField.tsx new file mode 100644 index 0000000..45098e8 --- /dev/null +++ b/app/[lang]/components/form/SelectField.tsx @@ -0,0 +1,200 @@ +import { Fragment, useRef } from "react"; + +import { Listbox, Transition } from "@headlessui/react"; +import { useTranslations } from "next-intl"; +import { Controller, useFormContext } from "react-hook-form"; +import { + IoChevronDown, + IoCloseOutline, + IoSearchOutline, +} from "react-icons/io5"; +import { twMerge } from "tailwind-merge"; + +import { Field, FieldProps } from "./Field"; + +export type SelectOption = { + value: string; + label: React.ReactNode; + selectedLabel?: React.ReactNode; + disabled?: boolean; +}; + +type SelectFieldProps = FieldProps & { + required?: boolean; + placeholder?: string; + noOptionsMessage?: string; + options: SelectOption[]; + searchable?: boolean; + listboxClassName?: string; + dropdownClassName?: string; + searchValue?: string; + onChangeSearchValue?: (value: string) => void; + onOptionSelected?: (option: SelectOption) => void; +}; + +export const SelectField = ({ + name, + label, + className, + labelClassName, + childrenWrapperClassName, + hideErrorMessage = false, + + required, + placeholder, + noOptionsMessage, + options, + searchable, + searchValue = "", + onChangeSearchValue, + onOptionSelected, + listboxClassName, + dropdownClassName, +}: SelectFieldProps) => { + const at = useTranslations("App"); + const form = useFormContext(); + + // We need to keep track of the selected option to be able to continue to + // display it when the user searches for something else + const curOption = useRef(null); + + return ( + +
+ { + const selectedOption = + curOption.current?.value === value + ? curOption.current + : options.find((option) => option.value === value); + + return ( + { + curOption.current = + options.find((option) => option.value === value) ?? null; + onChangeSearchValue?.(""); + onChange(value); + onOptionSelected?.(curOption.current!); + }} + > + {({ open }) => ( +
+ + + {selectedOption?.selectedLabel ?? + selectedOption?.label ?? + placeholder ?? + at("selectPlaceholder")} + + + + + + +
ul]:outline-none", + "border-gray-300 bg-gray-50 text-gray-900", + "dark:border-gray-600 dark:bg-gray-700 dark:text-white", + + dropdownClassName, + )} + > + + {searchable && ( +
+ + onChangeSearchValue(e.target.value) + : undefined + } + placeholder={at("searchPlaceholder")} + type="search" + /> + {searchValue.trim() !== "" && ( + + )} +
+ )} + + {options.length === 0 ? ( +
+ {noOptionsMessage ?? at("noOptionsMessage")} +
+ ) : ( +
+ {options.map((option) => ( + + {option.label} + + ))} +
+ )} +
+
+
+
+ )} +
+ ); + }} + /> +
+
+ ); +}; diff --git a/app/[lang]/components/form/SwitchField.tsx b/app/[lang]/components/form/SwitchField.tsx new file mode 100644 index 0000000..f9217f1 --- /dev/null +++ b/app/[lang]/components/form/SwitchField.tsx @@ -0,0 +1,60 @@ +import React from "react"; + +import { Switch, SwitchProps } from "@headlessui/react"; +import { Controller, useFormContext } from "react-hook-form"; +import { twMerge } from "tailwind-merge"; + +import { Field, FieldProps } from "./Field"; + +export const SwitchField = (props: FieldProps & SwitchProps<"button">) => { + const { + name, + label, + disabled, + className, + labelClassName, + + childrenWrapperClassName, + hideErrorMessage, + + ...rest + } = props; + const form = useFormContext(); + + return ( + +
+ ( +
+ + + +
+ )} + /> +
+
+ ); +}; diff --git a/app/[lang]/components/form/TextAreaField.tsx b/app/[lang]/components/form/TextAreaField.tsx new file mode 100644 index 0000000..f92922e --- /dev/null +++ b/app/[lang]/components/form/TextAreaField.tsx @@ -0,0 +1,88 @@ +import React from "react"; + +import { get } from "lodash"; +import { Controller, useFormContext } from "react-hook-form"; +import { twMerge } from "tailwind-merge"; + +import { Field, FieldProps } from "./Field"; + +export const TextAreaField = ( + props: FieldProps & + React.HTMLProps & { + handleChanged?: (props: { name: string }) => void; + startIcon?: React.ReactNode; + }, +) => { + const { + name, + label, + required, + disabled, + className, + labelClassName, + childrenWrapperClassName, + hideErrorMessage = false, + + startIcon, + handleChanged, + + ...rest + } = props; + const form = useFormContext(); + + const { + formState: { errors }, + } = form; + + const hasError = !!get(errors, name)?.message; + + return ( + +
+ ( +
+ {startIcon && ( +
+ {startIcon} +
+ )} +