mirror of
https://github.com/TheRealOwenRees/echecsfrance.git
synced 2026-07-23 04:26:57 +00:00
selection of club and players passes to form data on submit
This commit is contained in:
@@ -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<typeof matchSchema>;
|
||||
|
||||
interface IProps {
|
||||
clubs: Club[];
|
||||
}
|
||||
|
||||
const MatchForm = ({ clubs }: IProps) => {
|
||||
const form = useForm<MatchFormValues>({
|
||||
// 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 (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="w-3/4 space-y-8">
|
||||
<div className="grid grid-cols-4 items-start gap-6">
|
||||
<TeamSelection
|
||||
clubOptions={clubOptions}
|
||||
name="team1"
|
||||
label="Team 1"
|
||||
/>
|
||||
<TeamSelection
|
||||
clubOptions={clubOptions}
|
||||
name="team2"
|
||||
label="Team 2"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default MatchForm;
|
||||
@@ -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 (
|
||||
<div className="col-span-2 space-y-4">
|
||||
<label className="mb-2 block text-sm font-medium text-gray-900">
|
||||
{label}
|
||||
</label>
|
||||
|
||||
{/* Team/Club Select */}
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
{...field}
|
||||
instanceId={`${name}-select`}
|
||||
options={clubOptions}
|
||||
isSearchable
|
||||
placeholder="Select a club..."
|
||||
onChange={(option) => {
|
||||
field.onChange(option?.value);
|
||||
// Optional: Clear players if club changes, e.g. remove(0, fields.length)
|
||||
}}
|
||||
value={clubOptions.find((c) => c.value === field.value)}
|
||||
classNames={{
|
||||
control: () => "border-gray-300 rounded-md shadow-sm",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Dynamic Player List */}
|
||||
<div className="space-y-3">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<Controller
|
||||
name={`${name}_players.${index}.playerId`} // Saves as { playerId: "nrFFE_123" }
|
||||
control={control}
|
||||
render={({ field: selectField }) => (
|
||||
<Select
|
||||
{...selectField}
|
||||
instanceId={`${name}-player-${index}`}
|
||||
options={playerOptions}
|
||||
isSearchable
|
||||
placeholder="Search player..."
|
||||
onChange={(option) => 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",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(index)}
|
||||
className="p-2 text-red-500 hover:text-red-700"
|
||||
title="Remove player"
|
||||
>
|
||||
<FaMinus />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add Button */}
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-gray-500">Loading players...</p>
|
||||
) : (
|
||||
teamsPlayers[name]?.length > 0 && (
|
||||
<button
|
||||
type="button" // Important to prevent form submission
|
||||
onClick={() => append({ playerId: "" })} // Add new empty row to field array
|
||||
className="flex items-center gap-2 text-sm font-medium text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<FaPlus />
|
||||
Add Player
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamSelection;
|
||||
@@ -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>((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 (
|
||||
<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">
|
||||
Match
|
||||
</h2>
|
||||
|
||||
<p className="mb-8 text-center font-light text-gray-500 dark:text-gray-400 sm:text-xl lg:mb-16">
|
||||
Match
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MatchForm clubs={clubs} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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<any>([
|
||||
key: "selection",
|
||||
},
|
||||
]);
|
||||
|
||||
export const clubPlayersAtom = atom<Record<string, Player[]>>({});
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface Player {
|
||||
nrFFE: string;
|
||||
name: string;
|
||||
elo: string;
|
||||
elo_rapid: string;
|
||||
elo_blitz: string;
|
||||
category: string;
|
||||
club: string;
|
||||
}
|
||||
+23
-2
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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 ?? "",
|
||||
|
||||
@@ -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<typeof outputSchema>;
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user