diff --git a/src/app/[locale]/(main)/match/MatchForm.tsx b/src/app/[locale]/(main)/match/MatchForm.tsx new file mode 100644 index 0000000..6510f4d --- /dev/null +++ b/src/app/[locale]/(main)/match/MatchForm.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { FormProvider, useForm } from "react-hook-form"; +import Select from "react-select"; +import { z } from "zod"; + +import TeamSelection from "@/app/[locale]/(main)/match/components/TeamSelection"; +import { matchSchema } from "@/schemas"; +import { Club } from "@/types"; + +type MatchFormValues = z.infer; + +interface IProps { + clubs: Club[]; +} + +const MatchForm = ({ clubs }: IProps) => { + const form = useForm({ + // resolver: zodResolver(addTournamentSchema), + // defaultValues: { + // tournament: { + // norm_tournament: false, + // coordinates: [47.0844, 2.3964], + // }, + // }, + }); + + const onSubmit = async (data: MatchFormValues) => { + try { + console.log(data); + // await addTournament(data); + // setResponseMessage({ + // type: "success", + // message: t("success"), + // }); + + form.reset(); + } catch (err: unknown) { + console.log(err); + // + // setResponseMessage({ + // type: "error", + // message: t("failure"), + // }); + } + }; + + const clubOptions = clubs.map((club) => ({ + value: club.name, + label: club.name, + url: club.url, + })); + + return ( + +
+
+ + +
+ +
+
+ ); +}; + +export default MatchForm; diff --git a/src/app/[locale]/(main)/match/components/TeamSelection.tsx b/src/app/[locale]/(main)/match/components/TeamSelection.tsx new file mode 100644 index 0000000..48f364a --- /dev/null +++ b/src/app/[locale]/(main)/match/components/TeamSelection.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { useAtom } from "jotai"; +import { + Controller, + useFieldArray, + useFormContext, + useWatch, +} from "react-hook-form"; +import { FaMinus, FaPlus } from "react-icons/fa"; +import Select from "react-select"; + +import { clubPlayersAtom } from "@/atoms"; +import { fetchClubPlayers } from "@/server/fetchClubPlayers"; + +interface IProps { + name: string; + label: string; + clubOptions: { + value: string; + label: string; + url: string; + }[]; +} + +const TeamSelection = ({ name, label, clubOptions }: IProps) => { + const [isLoading, setIsLoading] = useState(false); + + const { control } = useFormContext(); + const [teamsPlayers, setTeamsPlayers] = useAtom(clubPlayersAtom); + + const { fields, append, remove } = useFieldArray({ + control, + name: `${name}_players`, // e.g., "team1_players" + }); + + const selectedDbId = useWatch({ + control, + name: name, + }); + + const selectedOption = clubOptions.find((c) => c.value === selectedDbId); + const selectedId = selectedOption?.url.split("=").pop(); + + useEffect(() => { + const fetchPlayers = async () => { + if (!selectedId) return; + + setIsLoading(true); + + try { + const response = await fetchClubPlayers({ clubId: selectedId }); + const data = response?.data; + + if (data) { + setTeamsPlayers((prev) => ({ + ...prev, + [name]: data, + })); + } + } catch (error) { + console.error(error); + } finally { + setIsLoading(false); + } + }; + + fetchPlayers(); + }, [selectedId, name, setTeamsPlayers]); + + const playerOptions = + teamsPlayers[name]?.map((player) => ({ + value: { + nrFFE: player.nrFFE, + name: player.name, + elo: player.elo, + }, + label: `${player.name} (${player.elo})`, + })) || []; + + return ( +
+ + + {/* Team/Club Select */} + ( + selectField.onChange(option?.value)} + // Compare values using strict equality since value is now a string (nrFFE) + value={playerOptions.find( + (op) => op.value === selectField.value, + )} + classNames={{ + control: () => "border-gray-300 rounded-md shadow-sm", + }} + /> + )} + /> +
+ + + ))} + + + {/* Add Button */} + {isLoading ? ( +

Loading players...

+ ) : ( + teamsPlayers[name]?.length > 0 && ( + + ) + )} + + ); +}; + +export default TeamSelection; diff --git a/src/app/[locale]/(main)/match/page.tsx b/src/app/[locale]/(main)/match/page.tsx new file mode 100644 index 0000000..00898dd --- /dev/null +++ b/src/app/[locale]/(main)/match/page.tsx @@ -0,0 +1,63 @@ +import { unstable_cache } from "next/cache"; + +import MatchForm from "@/app/[locale]/(main)/match/MatchForm"; +import { collections, dbConnect } from "@/server/mongodb"; +import { Club } from "@/types"; +import { filterClubsByManualEntry } from "@/utils/clubFilters"; +import { errorLog } from "@/utils/logger"; + +const getClubs = async () => { + try { + await dbConnect(); + const data = await collections + .clubs!.find({ pending: { $ne: true } }) + .sort({ name: 1 }) + .toArray(); + + return filterClubsByManualEntry(data) + .filter((c) => !!c.coordinates) + .map((club) => { + return { + id: club._id.toString(), + name: club.name, + url: club.url, + address: club.address, + email: club.email, + website: club.website, + latLng: { lat: club.coordinates[0], lng: club.coordinates[1] }, + }; + }); + } catch (error) { + errorLog(error); + throw new Error("Error fetching club data"); + } +}; + +export default async function Match() { + const clubs = await unstable_cache( + async () => { + const data = await getClubs(); + return data; + }, + ["clubs"], + { + revalidate: 60 * 60 * 6, + }, + )(); + + return ( +
+
+

+ Match +

+ +

+ Match +

+
+ + +
+ ); +} diff --git a/src/atoms.ts b/src/atoms.ts index c1002dc..5c5e6d2 100644 --- a/src/atoms.ts +++ b/src/atoms.ts @@ -18,6 +18,7 @@ import { import { atom } from "jotai"; import { LatLngBounds } from "leaflet"; +import { Player } from "@/interfaces"; import { Club, TimeControl, Tournament } from "@/types"; import atomWithDebounce from "@/utils/atomWithDebounce"; import { normalizedContains } from "@/utils/string"; @@ -228,3 +229,5 @@ export const dateRangeAtom = atom([ key: "selection", }, ]); + +export const clubPlayersAtom = atom>({}); diff --git a/src/interfaces.ts b/src/interfaces.ts new file mode 100644 index 0000000..fbffa67 --- /dev/null +++ b/src/interfaces.ts @@ -0,0 +1,9 @@ +export interface Player { + nrFFE: string; + name: string; + elo: string; + elo_rapid: string; + elo_blitz: string; + category: string; + club: string; +} diff --git a/src/schemas.ts b/src/schemas.ts index e32ca96..c84e46e 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -20,8 +20,8 @@ export const addClubSchema = z.object({ address: z.string().min(1, { message: "FormValidation.required" }), website: z.string().url({ message: "FormValidation.url" }).optional(), coordinates: z.array(z.number()).length(2), - }) -}) + }), +}); export const addTournamentSchema = z.object({ name: z.string().min(1, { message: "FormValidation.required" }), @@ -59,3 +59,24 @@ export const zoneSchema = z message: "FormValidation.zone", path: ["features"], }); + +// export const fetchClubPlayersSchema = z.array( +// z.object({ +// nrFFE: z.string(), +// name: z.string(), +// elo: z.string(), +// elo_rapid: z.string(), +// elo_blitz: z.string(), +// category: z.string(), +// club: z.string(), +// }), +// ); + +export const fetchClubPlayersSchema = z.object({ + clubId: z.string(), +}); + +export const matchSchema = z.object({ + team1: z.string(), + team2: z.string(), +}); diff --git a/src/server/addClub.ts b/src/server/addClub.ts index 6308eb0..aea3ee6 100644 --- a/src/server/addClub.ts +++ b/src/server/addClub.ts @@ -11,8 +11,6 @@ export const addClub = actionClient try { const { name, email, message, club } = input.parsedInput; - console.log(club); - const clubData = { name: club.name ?? "", address: club.address ?? "", diff --git a/src/server/fetchClubPlayers.ts b/src/server/fetchClubPlayers.ts new file mode 100644 index 0000000..b6028f2 --- /dev/null +++ b/src/server/fetchClubPlayers.ts @@ -0,0 +1,100 @@ +"use server"; + +import { z } from "zod"; + +import { fetchClubPlayersSchema } from "@/schemas"; +import { errorLog } from "@/utils/logger"; + +import { actionClient } from "./safeAction"; + +const resultsSchema = z.array( + z.object({ + nrFFE: z.string(), + name: z.string(), + elo: z.string(), + elo_rapid: z.string(), + elo_blitz: z.string(), + category: z.string(), + club: z.string(), + }), +); + +const outputSchema = z.array( + z.object({ + nrFFE: z.string(), + name: z.string(), + elo: z.string(), + elo_rapid: z.string(), + elo_blitz: z.string(), + category: z.string(), + club: z.string(), + }), +); + +export type ClubPlayerResultsData = z.infer; + +const reportFetchError = async (url: string, error: any) => { + await fetch(process.env.DISCORD_WEBHOOK_ERROR_LOGS_URL as string, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + embeds: [ + { + title: "Error Fetching Club Player Details", + fields: [ + { name: "url", value: url }, + { + name: "error.message", + value: "message" in error ? error.message : "", + }, + { name: "error", value: JSON.stringify(error, null, 2) }, + ], + }, + ], + }), + }); +}; + +export const fetchClubPlayers = actionClient + .schema(fetchClubPlayersSchema) + .action(async (input) => { + const { clubId } = input.parsedInput; + + try { + const headers = new Headers(); + const apiKey = process.env.RESULTS_API_KEY; + + if (apiKey) { + headers.append("api-key", apiKey); + } + + const rawResults = await fetch( + `https://api.echecsfrance.com/api/v1/club_player_details/${clubId}`, + { + headers: headers, + }, + ); + + if (rawResults.status !== 200) { + throw new Error("ERR_CLUB_NOT_FOUND"); + } + + const results = await rawResults.json(); + + const parsedResults = resultsSchema.parse(results); + + return outputSchema.parse(parsedResults); + } catch (error: any) { + if ( + !("message" in error) || + error.message !== "ERR_TOURNAMENT_NOT_FOUND" + ) { + // reportFetchError(id, error); + errorLog(JSON.stringify(error, null, 2)); + } + + throw error; + } + });