add /add-club route

remove commented out code

dismiss success dialog - onClick was missing

locale for add-club url

prefer manual entry club if both auto and manual entry versions exist, only if pending = false

change manual entry field to a boolean

filter out clubs that are pending approval from manual entry

add info message on success

tournament form adds to DB and send discord message

ping discord with club submission details

i18n for add club

update map to allow coordinate update depending on tournament/club page

basic add club form with reusable map component
This commit is contained in:
2025-11-20 12:36:20 +01:00
committed by Owen Rees
parent 67e97b6ca0
commit e62feef1a9
22 changed files with 1943 additions and 20 deletions
@@ -0,0 +1,179 @@
"use client";
import { useState } from "react";
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 { Button } from "@/components/Button";
import InfoMessage, { InfoMessageType } from "@/components/InfoMessage";
import LoadingMap from "@/components/LoadingMap";
import { TextAreaField } from "@/components/form/TextAreaField";
import { TextField } from "@/components/form/TextField";
import { addClubSchema } from "@/schemas";
import { addClub } from "@/server/addClub";
import { errorLog } from "@/utils/logger";
type ClubFormValues = z.infer<typeof addClubSchema>;
const Map = dynamic(() => import("../components/MapCoordinateSelection"), {
ssr: false,
loading: LoadingMap,
});
const ClubForm = () => {
const t = useTranslations("AddClub");
const [responseMessage, setResponseMessage] = useState<{
type: InfoMessageType;
message: string;
} | null>(null);
const form = useForm<ClubFormValues>({
resolver: zodResolver(addClubSchema),
defaultValues: {
club: {
coordinates: [47.0844, 2.3964],
},
},
});
const onSubmit = async (data: ClubFormValues) => {
try {
await addClub(data);
setResponseMessage({
type: "success",
message: t("success"),
});
form.reset();
} catch (err: unknown) {
errorLog(err);
setResponseMessage({
type: "error",
message: t("failure"),
});
}
};
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<div className="grid grid-cols-4 items-start gap-6">
<div className="col-span-4">
<TextField
name="club.name"
control={form.control}
label={t("clubNameLabel")}
placeholder={t("clubNamePlaceholder")}
required
/>
</div>
<div className="col-span-4">
<TextField
name="club.address"
control={form.control}
label={"Address:"}
placeholder={t("addressPlaceholder")}
required
/>
</div>
<div className="col-span-4">
<TextField
name="club.email"
control={form.control}
label={t("emailLabel")}
placeholder={t("emailPlaceholder")}
required
/>
</div>
<div className="col-span-4">
<TextField
name="club.website"
control={form.control}
label={t("websiteLabel")}
placeholder={t("websitePlaceholder")}
required
/>
</div>
<div className="col-span-2 sm:col-span-1">
<TextField
name="name"
control={form.control}
label={t("yourNameLabel")}
placeholder={t("yourNamePlaceholder")}
required
/>
</div>
<div className="col-span-2 sm:col-span-1">
<TextField
name="email"
control={form.control}
label={t("yourEmailLabel")}
placeholder={t("yourEmailPlaceholder")}
required
/>
</div>
<div className="col-span-4 row-span-2 self-stretch sm:col-span-2">
<TextAreaField
className="h-full"
childrenWrapperClassName="h-full"
name="message"
control={form.control}
label={t("messageLabel")}
placeholder={t("messagePlaceholder")}
/>
</div>
<div className="col-span-2 sm:col-span-1">
<TextField
name="club.coordinates.0"
control={form.control}
label={t("latLabel")}
type="number"
required
/>
</div>
<div className="col-span-2 sm:col-span-1">
<TextField
name="club.coordinates.1"
control={form.control}
label={t("lngLabel")}
type="number"
required
/>
</div>
<section id="map" className="z-0 col-span-4 flex h-auto">
<Map page="club" />
</section>
</div>
<div className="flex gap-4">
<Button disabled={form.formState.isSubmitting} type="submit">
{form.formState.isSubmitting ? t("sending") : t("sendButton")}
</Button>
<Button onClick={() => form.reset()} type="button" intent="secondary">
{t("clearForm")}
</Button>
</div>
{responseMessage && (
<InfoMessage
message={responseMessage.message}
type={responseMessage.type}
onDismiss={() => setResponseMessage(null)}
/>
)}
</form>
</FormProvider>
);
};
export default ClubForm;
+20
View File
@@ -0,0 +1,20 @@
import ClubForm from "@/app/[locale]/(main)/add-club/ClubForm";
import { useTranslations } from "next-intl";
export default function AddClubPage() {
const t = useTranslations("AddClub");
return (
<section className="grid place-items-center bg-white pb-20 dark:bg-gray-800">
<div className="mx-auto max-w-screen-md px-4 py-8 lg:py-16">
<h2 className="mb-4 text-center text-4xl font-extrabold tracking-tight text-gray-900 dark:text-white">{t("title")}</h2>
<p className="mb-8 text-center font-light text-gray-500 dark:text-gray-400 sm:text-xl lg:mb-16">
{t("info")}
</p>
<ClubForm />
</div>
</section>
)
}
@@ -23,7 +23,7 @@ import { TimeControl } from "@/types";
type TournamentFormValues = z.infer<typeof addTournamentSchema>;
const Map = dynamic(() => import("./Map"), {
const Map = dynamic(() => import("../components/MapCoordinateSelection"), {
ssr: false,
loading: LoadingMap,
});
@@ -205,7 +205,7 @@ const TournamentForm = () => {
</div>
<section id="map" className="z-0 col-span-4 flex h-auto">
<Map />
<Map page="tournament" />
</section>
</div>
+6 -2
View File
@@ -2,6 +2,7 @@ import { unstable_cache } from "next/cache";
import { collections, dbConnect } from "@/server/mongodb";
import { Club } from "@/types";
import { filterClubsByManualEntry } from "@/utils/clubFilters";
import { errorLog } from "@/utils/logger";
import ClubsDisplay from "./ClubsDisplay";
@@ -11,9 +12,12 @@ export const revalidate = 3600; // Revalidate cache every 6 hours
const getClubs = async () => {
try {
await dbConnect();
const data = await collections.clubs!.find({}).sort({ name: 1 }).toArray();
const data = await collections
.clubs!.find({ pending: { $ne: true } })
.sort({ name: 1 })
.toArray();
return data
return filterClubsByManualEntry(data)
.filter((c) => !!c.coordinates)
.map<Club>((club) => {
return {
@@ -15,11 +15,11 @@ import { MapEvents } from "@/components/MapEvents";
const center: LatLngLiteral = { lat: 47.0844, lng: 2.3964 };
const Map = () => {
const MapCoordinateSelection = ( { page }: {page: "club" | "tournament"}) => {
const { control, setValue } = useFormContext();
const latValue = useWatch({ control, name: "tournament.coordinates.0" });
const lngValue = useWatch({ control, name: "tournament.coordinates.1" });
const latValue = useWatch({ control, name: `${page}.coordinates.0` });
const lngValue = useWatch({ control, name: `${page}.coordinates.1` });
const setMapBounds = useSetAtom(mapBoundsAtom);
const markerRef = useRef<L.Marker | null>(null);
@@ -31,17 +31,17 @@ const Map = () => {
if (marker != null) {
const position = marker.getLatLng();
setValue(
"tournament.coordinates.0",
`${page}.coordinates.0`,
Math.round((position.lat + Number.EPSILON) * 10000) / 10000,
);
setValue(
"tournament.coordinates.1",
`${page}.coordinates.1`,
Math.round((position.lng + Number.EPSILON) * 10000) / 10000,
);
}
},
}),
[setValue],
[setValue, page],
);
return (
@@ -71,4 +71,4 @@ const Map = () => {
);
};
export default Map;
export default MapCoordinateSelection;