mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-28 10:17:12 -05:00
* Remove old code * Add prefetching * Elim bracket initial * Hide rounds with only byes * Round hiding logic * Align stuff * Add TODO * Adjustments * Deadline * Compactify button * Simulations * Round robin bracket initial * eventId -> tournamentId * seedByTeamId removed * Couple more TODOs * RR placements table * Locking matches * Extract TournamentStream component * Bracket streams * Remove extras for tournament-manager, misc * Fix E2E tests * Fix SKALOP_SYSTEM_MESSAGE_URL in env.example * TODOs * TODO moved to GitHub * Handle team changing in match cache invalidation * Fix streamer seeing undo last score button * Show "Sub" badge on team roster page * Show who didn't play yet on match teams preview * Ranked/unranked badge * Bracket hover show roster * Add lock/unlock match test * Fix score reporting
1079 lines
32 KiB
TypeScript
1079 lines
32 KiB
TypeScript
import {
|
|
redirect,
|
|
type ActionFunction,
|
|
type LoaderFunctionArgs,
|
|
} from "@remix-run/node";
|
|
import { Link, useFetcher, useLoaderData } from "@remix-run/react";
|
|
import clsx from "clsx";
|
|
import * as React from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useCopyToClipboard } from "react-use";
|
|
import invariant from "tiny-invariant";
|
|
import { Alert } from "~/components/Alert";
|
|
import { Avatar } from "~/components/Avatar";
|
|
import { Button } from "~/components/Button";
|
|
import { Divider } from "~/components/Divider";
|
|
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
|
import { Image, ModeImage } from "~/components/Image";
|
|
import { Input } from "~/components/Input";
|
|
import { Label } from "~/components/Label";
|
|
import { Popover } from "~/components/Popover";
|
|
import { SubmitButton } from "~/components/SubmitButton";
|
|
import { CheckmarkIcon } from "~/components/icons/Checkmark";
|
|
import { ClockIcon } from "~/components/icons/Clock";
|
|
import { CrossIcon } from "~/components/icons/Cross";
|
|
import { UserIcon } from "~/components/icons/User";
|
|
import { useUser } from "~/features/auth/core";
|
|
import { getUser, requireUser } from "~/features/auth/core/user.server";
|
|
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
|
import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps";
|
|
import * as TeamRepository from "~/features/team/TeamRepository.server";
|
|
import { findMapPoolByTeamId } from "~/features/tournament-bracket";
|
|
import type { TournamentData } from "~/features/tournament-bracket/core/Tournament.server";
|
|
import {
|
|
tournamentFromDB,
|
|
type TournamentDataTeam,
|
|
} from "~/features/tournament-bracket/core/Tournament.server";
|
|
import { useAutoRerender } from "~/hooks/useAutoRerender";
|
|
import { useIsMounted } from "~/hooks/useIsMounted";
|
|
import type {
|
|
ModeShort,
|
|
RankedModeShort,
|
|
StageId,
|
|
} from "~/modules/in-game-lists";
|
|
import { stageIds } from "~/modules/in-game-lists";
|
|
import { rankedModesShort } from "~/modules/in-game-lists/modes";
|
|
import {
|
|
notFoundIfFalsy,
|
|
parseRequestFormData,
|
|
validate,
|
|
type SendouRouteHandle,
|
|
} from "~/utils/remix";
|
|
import { booleanToInt } from "~/utils/sql";
|
|
import { discordFullName } from "~/utils/strings";
|
|
import { assertUnreachable } from "~/utils/types";
|
|
import {
|
|
CALENDAR_PAGE,
|
|
LOG_IN_URL,
|
|
SENDOU_INK_BASE_URL,
|
|
modeImageUrl,
|
|
navIconUrl,
|
|
tournamentBracketsPage,
|
|
tournamentJoinPage,
|
|
tournamentSubsPage,
|
|
} from "~/utils/urls";
|
|
import { checkIn } from "../queries/checkIn.server";
|
|
import { createTeam } from "../queries/createTeam.server";
|
|
import { deleteTeam } from "../queries/deleteTeam.server";
|
|
import deleteTeamMember from "../queries/deleteTeamMember.server";
|
|
import { findByIdentifier } from "../queries/findByIdentifier.server";
|
|
import { findOwnTeam } from "../queries/findOwnTeam.server";
|
|
import { findTeamsByTournamentId } from "../queries/findTeamsByTournamentId.server";
|
|
import type { TrustedPlayer } from "../queries/findTrustedPlayers.server";
|
|
import { findTrustedPlayers } from "../queries/findTrustedPlayers.server";
|
|
import hasTournamentStarted from "../queries/hasTournamentStarted.server";
|
|
import { joinTeam } from "../queries/joinLeaveTeam.server";
|
|
import { updateTeamInfo } from "../queries/updateTeamInfo.server";
|
|
import { upsertCounterpickMaps } from "../queries/upsertCounterpickMaps.server";
|
|
import { TOURNAMENT } from "../tournament-constants";
|
|
import { useSelectCounterpickMapPoolState } from "../tournament-hooks";
|
|
import { registerSchema } from "../tournament-schemas.server";
|
|
import {
|
|
HACKY_isInviteOnlyEvent,
|
|
isOneModeTournamentOf,
|
|
tournamentIdFromParams,
|
|
} from "../tournament-utils";
|
|
import { useTournament } from "./to.$id";
|
|
|
|
export const handle: SendouRouteHandle = {
|
|
breadcrumb: () => ({
|
|
imgPath: navIconUrl("calendar"),
|
|
href: CALENDAR_PAGE,
|
|
type: "IMAGE",
|
|
}),
|
|
};
|
|
|
|
export const action: ActionFunction = async ({ request, params }) => {
|
|
const user = await requireUser(request);
|
|
const data = await parseRequestFormData({ request, schema: registerSchema });
|
|
|
|
const tournamentId = tournamentIdFromParams(params);
|
|
const tournament = await tournamentFromDB({ tournamentId, user });
|
|
const hasStarted = hasTournamentStarted(tournamentId);
|
|
const event = notFoundIfFalsy(findByIdentifier(tournamentId));
|
|
|
|
validate(
|
|
!hasStarted,
|
|
"Tournament has started, cannot make edits to registration",
|
|
);
|
|
|
|
const teams = findTeamsByTournamentId(tournamentId);
|
|
const ownTeam = teams.find((team) =>
|
|
team.members.some((member) => member.userId === user.id && member.isOwner),
|
|
);
|
|
|
|
switch (data._action) {
|
|
case "UPSERT_TEAM": {
|
|
if (ownTeam) {
|
|
updateTeamInfo({
|
|
name: data.teamName,
|
|
id: ownTeam.id,
|
|
prefersNotToHost: booleanToInt(data.prefersNotToHost),
|
|
});
|
|
} else {
|
|
validate(!HACKY_isInviteOnlyEvent(event), "Event is invite only");
|
|
|
|
createTeam({
|
|
name: data.teamName,
|
|
tournamentId: tournamentId,
|
|
ownerId: user.id,
|
|
prefersNotToHost: booleanToInt(data.prefersNotToHost),
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
case "DELETE_TEAM_MEMBER": {
|
|
validate(ownTeam);
|
|
validate(ownTeam.members.some((member) => member.userId === data.userId));
|
|
validate(data.userId !== user.id);
|
|
|
|
const detailedOwnTeam = findOwnTeam({
|
|
tournamentId,
|
|
userId: user.id,
|
|
});
|
|
// making sure they aren't unfilling one checking in condition i.e. having full roster
|
|
// and then having members kicked without it affecting the checking in status
|
|
validate(
|
|
detailedOwnTeam &&
|
|
(!detailedOwnTeam.checkedInAt ||
|
|
ownTeam.members.length > TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL),
|
|
);
|
|
|
|
deleteTeamMember({ tournamentTeamId: ownTeam.id, userId: data.userId });
|
|
break;
|
|
}
|
|
case "UPDATE_MAP_POOL": {
|
|
const mapPool = new MapPool(data.mapPool);
|
|
validate(ownTeam);
|
|
validate(
|
|
validateCounterPickMapPool(
|
|
mapPool,
|
|
isOneModeTournamentOf(event),
|
|
tournament.ctx.tieBreakerMapPool,
|
|
) === "VALID",
|
|
);
|
|
|
|
upsertCounterpickMaps({
|
|
tournamentTeamId: ownTeam.id,
|
|
mapPool: new MapPool(data.mapPool),
|
|
});
|
|
break;
|
|
}
|
|
case "CHECK_IN": {
|
|
validate(tournament.regularCheckInIsOpen, "Check in is not open");
|
|
validate(ownTeam);
|
|
validate(!ownTeam.checkedInAt, "You have already checked in");
|
|
validate(
|
|
tournament.checkInConditionsFulfilled({
|
|
tournamentTeamId: ownTeam.id,
|
|
mapPool: findMapPoolByTeamId(ownTeam.id),
|
|
}),
|
|
);
|
|
|
|
checkIn(ownTeam.id);
|
|
break;
|
|
}
|
|
case "ADD_PLAYER": {
|
|
validate(
|
|
teams.every((team) =>
|
|
team.members.every((member) => member.userId !== data.userId),
|
|
),
|
|
"User is already in a team",
|
|
);
|
|
validate(ownTeam);
|
|
validate(
|
|
findTrustedPlayers({
|
|
userId: user.id,
|
|
teamId: (await TeamRepository.findByUserId(user.id))?.id,
|
|
}).some((trustedPlayer) => trustedPlayer.id === data.userId),
|
|
"No trust given from this user",
|
|
);
|
|
|
|
joinTeam({
|
|
userId: data.userId,
|
|
newTeamId: ownTeam.id,
|
|
tournamentId,
|
|
});
|
|
break;
|
|
}
|
|
case "UNREGISTER": {
|
|
validate(ownTeam, "You are not registered to this tournament");
|
|
validate(!ownTeam.checkedInAt, "You cannot unregister after checking in");
|
|
|
|
deleteTeam(ownTeam.id);
|
|
break;
|
|
}
|
|
default: {
|
|
assertUnreachable(data);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
export type TournamentRegisterPageLoader = typeof loader;
|
|
|
|
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
|
const eventId = tournamentIdFromParams(params);
|
|
const hasStarted = hasTournamentStarted(eventId);
|
|
|
|
if (hasStarted) {
|
|
throw redirect(tournamentBracketsPage({ tournamentId: eventId }));
|
|
}
|
|
|
|
const user = await getUser(request);
|
|
if (!user) return null;
|
|
|
|
const ownTeam = findOwnTeam({
|
|
tournamentId: tournamentIdFromParams(params),
|
|
userId: user.id,
|
|
});
|
|
if (!ownTeam) return null;
|
|
|
|
return {
|
|
mapPool: findMapPoolByTeamId(ownTeam.id),
|
|
trustedPlayers: findTrustedPlayers({
|
|
userId: user.id,
|
|
teamId: (await TeamRepository.findByUserId(user.id))?.id,
|
|
}),
|
|
};
|
|
};
|
|
|
|
export default function TournamentRegisterPage() {
|
|
const user = useUser();
|
|
const isMounted = useIsMounted();
|
|
const { t, i18n } = useTranslation(["tournament"]);
|
|
const tournament = useTournament();
|
|
|
|
const isRegularMemberOfATeam =
|
|
tournament.teamMemberOfByUser(user) && !tournament.ownedTeamByUser(user);
|
|
|
|
return (
|
|
<div className="stack lg">
|
|
<div className="tournament__logo-container">
|
|
<img
|
|
src={tournament.logoSrc}
|
|
alt=""
|
|
className="tournament__logo"
|
|
width={124}
|
|
height={124}
|
|
/>
|
|
<div>
|
|
<div className="tournament__title">{tournament.ctx.name}</div>
|
|
<div className="stack horizontal sm">
|
|
{tournament.ranked ? (
|
|
<div className="tournament__badge tournament__badge__ranked">
|
|
Ranked
|
|
</div>
|
|
) : (
|
|
<div className="tournament__badge tournament__badge__unranked">
|
|
Unranked
|
|
</div>
|
|
)}
|
|
<div className="tournament__badge tournament__badge__modes">
|
|
{tournament.modesIncluded.map((mode) => (
|
|
<ModeImage key={mode} mode={mode} size={16} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="tournament__by mt-1">
|
|
<div className="stack horizontal xs items-center">
|
|
<UserIcon className="tournament__info__icon" />{" "}
|
|
{tournament.ctx.author.discordName}
|
|
</div>
|
|
<div className="stack horizontal xs items-center">
|
|
<ClockIcon className="tournament__info__icon" />{" "}
|
|
{isMounted
|
|
? tournament.ctx.startTime.toLocaleString(i18n.language, {
|
|
timeZoneName: "short",
|
|
minute: "numeric",
|
|
hour: "numeric",
|
|
day: "numeric",
|
|
month: "numeric",
|
|
})
|
|
: null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div>{tournament.ctx.description}</div>
|
|
{isRegularMemberOfATeam ? (
|
|
<Alert>{t("tournament:pre.inATeam")}</Alert>
|
|
) : (
|
|
<RegistrationForms />
|
|
)}
|
|
{!tournament.teamMemberOfByUser(user) && tournament.subsFeatureEnabled ? (
|
|
<Link
|
|
to={tournamentSubsPage(tournament.ctx.id)}
|
|
className="text-xs text-center"
|
|
>
|
|
{t("tournament:pre.sub.prompt")}
|
|
</Link>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PleaseLogIn() {
|
|
const { t } = useTranslation(["tournament"]);
|
|
|
|
return (
|
|
<form className="stack items-center" action={LOG_IN_URL} method="post">
|
|
<Button size="big" type="submit">
|
|
{t("tournament:pre.logIn")}
|
|
</Button>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function RegistrationForms() {
|
|
const data = useLoaderData<typeof loader>();
|
|
const user = useUser();
|
|
const tournament = useTournament();
|
|
|
|
const ownTeam = tournament.ownedTeamByUser(user);
|
|
const ownTeamCheckedIn = Boolean(ownTeam && ownTeam.checkIns.length > 0);
|
|
|
|
if (!user && tournament.hasOpenRegistration) {
|
|
return <PleaseLogIn />;
|
|
}
|
|
|
|
const showRegistrationProgress = () => {
|
|
if (ownTeam) return true;
|
|
|
|
return tournament.hasOpenRegistration;
|
|
};
|
|
|
|
const showRegisterNewTeam = () => {
|
|
if (ownTeam) return true;
|
|
if (!tournament.hasOpenRegistration) return false;
|
|
|
|
return !tournament.regularCheckInHasEnded;
|
|
};
|
|
|
|
return (
|
|
<div className="stack lg">
|
|
{showRegistrationProgress() ? (
|
|
<RegistrationProgress
|
|
checkedIn={ownTeamCheckedIn}
|
|
name={ownTeam?.name}
|
|
mapPool={data?.mapPool}
|
|
members={ownTeam?.members}
|
|
/>
|
|
) : null}
|
|
{showRegisterNewTeam() ? (
|
|
<TeamInfo
|
|
name={ownTeam?.name}
|
|
prefersNotToHost={ownTeam?.prefersNotToHost}
|
|
canUnregister={Boolean(ownTeam && !ownTeamCheckedIn)}
|
|
/>
|
|
) : null}
|
|
{ownTeam ? (
|
|
<>
|
|
<FillRoster ownTeam={ownTeam} ownTeamCheckedIn={ownTeamCheckedIn} />
|
|
<CounterPickMapPoolPicker />
|
|
</>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RegistrationProgress({
|
|
checkedIn,
|
|
name,
|
|
members,
|
|
mapPool,
|
|
}: {
|
|
checkedIn?: boolean;
|
|
name?: string;
|
|
members?: unknown[];
|
|
mapPool?: unknown[];
|
|
}) {
|
|
const { t } = useTranslation(["tournament"]);
|
|
const tournament = useTournament();
|
|
|
|
const steps = [
|
|
{
|
|
name: t("tournament:pre.steps.name"),
|
|
completed: Boolean(name),
|
|
},
|
|
{
|
|
name: t("tournament:pre.steps.roster"),
|
|
completed:
|
|
members && members.length >= TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL,
|
|
},
|
|
{
|
|
name: t("tournament:pre.steps.pool"),
|
|
completed: mapPool && mapPool.length > 0,
|
|
},
|
|
{
|
|
name: t("tournament:pre.steps.check-in"),
|
|
completed: checkedIn,
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<h3 className="tournament__section-header text-center">
|
|
{t("tournament:pre.steps.header")}
|
|
</h3>
|
|
<section className="tournament__section stack md">
|
|
<div className="stack horizontal lg justify-center text-sm font-semi-bold">
|
|
{steps.map((step, i) => {
|
|
return (
|
|
<div
|
|
key={step.name}
|
|
className="stack sm items-center text-center"
|
|
>
|
|
{step.name}
|
|
{step.completed ? (
|
|
<CheckmarkIcon
|
|
className="tournament__section__icon fill-success"
|
|
testId={`checkmark-icon-num-${i + 1}`}
|
|
/>
|
|
) : (
|
|
<CrossIcon className="tournament__section__icon fill-error" />
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<CheckIn
|
|
canCheckIn={steps.filter((step) => !step.completed).length === 1}
|
|
status={
|
|
tournament.regularCheckInIsOpen
|
|
? "OPEN"
|
|
: tournament.regularCheckInHasEnded
|
|
? "OVER"
|
|
: "UPCOMING"
|
|
}
|
|
startDate={tournament.regularCheckInStartsAt}
|
|
endDate={tournament.regularCheckInEndsAt}
|
|
checkedIn={checkedIn}
|
|
/>
|
|
</section>
|
|
<div className="tournament__section__warning">
|
|
{t("tournament:pre.footer")}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CheckIn({
|
|
status,
|
|
canCheckIn,
|
|
startDate,
|
|
endDate,
|
|
checkedIn,
|
|
}: {
|
|
status: "OVER" | "OPEN" | "UPCOMING";
|
|
canCheckIn: boolean;
|
|
startDate: Date;
|
|
endDate: Date;
|
|
checkedIn?: boolean;
|
|
}) {
|
|
const { t, i18n } = useTranslation(["tournament"]);
|
|
const isMounted = useIsMounted();
|
|
const fetcher = useFetcher();
|
|
|
|
useAutoRerender();
|
|
|
|
const checkInStartsString = isMounted
|
|
? startDate.toLocaleTimeString(i18n.language, {
|
|
minute: "numeric",
|
|
hour: "numeric",
|
|
day: "2-digit",
|
|
month: "2-digit",
|
|
})
|
|
: "";
|
|
|
|
const checkInEndsString = isMounted
|
|
? endDate.toLocaleTimeString(i18n.language, {
|
|
minute: "numeric",
|
|
hour: "numeric",
|
|
day: "2-digit",
|
|
month: "2-digit",
|
|
})
|
|
: "";
|
|
|
|
if (status === "UPCOMING") {
|
|
return (
|
|
<div className={clsx("text-center text-xs", { invisible: !isMounted })}>
|
|
{t("tournament:pre.checkIn.range", {
|
|
start: checkInStartsString,
|
|
finish: checkInEndsString,
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (checkedIn) {
|
|
return (
|
|
<div className="text-center text-xs">
|
|
{t("tournament:pre.checkIn.checkedIn")}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (status === "OVER") {
|
|
return (
|
|
<div className="text-center text-xs">
|
|
{t("tournament:pre.checkIn.over")}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!canCheckIn) {
|
|
return (
|
|
<div className="stack items-center">
|
|
<Popover
|
|
buttonChildren={<>{t("tournament:pre.checkIn.button")}</>}
|
|
triggerClassName="tiny"
|
|
>
|
|
{t("tournament:pre.checkIn.cant")}
|
|
</Popover>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<fetcher.Form method="post" className="stack items-center">
|
|
<SubmitButton
|
|
size="tiny"
|
|
_action="CHECK_IN"
|
|
state={fetcher.state}
|
|
testId="check-in-button"
|
|
>
|
|
{t("tournament:pre.checkIn.button")}
|
|
</SubmitButton>
|
|
</fetcher.Form>
|
|
);
|
|
}
|
|
|
|
function TeamInfo({
|
|
name,
|
|
prefersNotToHost = 0,
|
|
canUnregister,
|
|
}: {
|
|
name?: string;
|
|
prefersNotToHost?: number;
|
|
canUnregister: boolean;
|
|
}) {
|
|
const { t } = useTranslation(["tournament", "common"]);
|
|
const fetcher = useFetcher();
|
|
|
|
return (
|
|
<div>
|
|
<div className="stack horizontal justify-between">
|
|
<h3 className="tournament__section-header">
|
|
1. {t("tournament:pre.info.header")}
|
|
</h3>
|
|
{canUnregister ? (
|
|
<FormWithConfirm
|
|
dialogHeading={t("tournament:pre.info.unregister.confirm")}
|
|
deleteButtonText={t("tournament:pre.info.unregister")}
|
|
fields={[["_action", "UNREGISTER"]]}
|
|
>
|
|
<Button
|
|
className="build__small-text"
|
|
variant="minimal-destructive"
|
|
size="tiny"
|
|
type="submit"
|
|
>
|
|
{t("tournament:pre.info.unregister")}
|
|
</Button>
|
|
</FormWithConfirm>
|
|
) : null}
|
|
</div>
|
|
<section className="tournament__section">
|
|
<fetcher.Form method="post" className="stack md items-center">
|
|
<div className="stack sm items-center">
|
|
<div className="tournament__section__input-container">
|
|
<Label htmlFor="teamName">{t("tournament:pre.steps.name")}</Label>
|
|
<Input
|
|
name="teamName"
|
|
id="teamName"
|
|
required
|
|
maxLength={TOURNAMENT.TEAM_NAME_MAX_LENGTH}
|
|
defaultValue={name ?? undefined}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<div className="text-lighter text-sm stack horizontal sm items-center">
|
|
<input
|
|
id="no-host"
|
|
type="checkbox"
|
|
name="prefersNotToHost"
|
|
defaultChecked={Boolean(prefersNotToHost)}
|
|
/>
|
|
<label htmlFor="no-host" className="mb-0">
|
|
{t("tournament:pre.info.noHost")}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<SubmitButton
|
|
_action="UPSERT_TEAM"
|
|
state={fetcher.state}
|
|
testId="save-team-button"
|
|
>
|
|
{t("common:actions.save")}
|
|
</SubmitButton>
|
|
</fetcher.Form>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FillRoster({
|
|
ownTeam,
|
|
ownTeamCheckedIn,
|
|
}: {
|
|
ownTeam: TournamentDataTeam;
|
|
ownTeamCheckedIn: boolean;
|
|
}) {
|
|
const data = useLoaderData<typeof loader>();
|
|
const user = useUser();
|
|
const tournament = useTournament();
|
|
const [, copyToClipboard] = useCopyToClipboard();
|
|
const { t } = useTranslation(["common", "tournament"]);
|
|
|
|
const inviteLink = `${SENDOU_INK_BASE_URL}${tournamentJoinPage({
|
|
tournamentId: tournament.ctx.id,
|
|
inviteCode: ownTeam.inviteCode!,
|
|
})}`;
|
|
|
|
const { members: ownTeamMembers } = tournament.ownedTeamByUser(user) ?? {};
|
|
invariant(ownTeamMembers, "own team members should exist");
|
|
|
|
const missingMembers = Math.max(
|
|
TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL - ownTeamMembers.length,
|
|
0,
|
|
);
|
|
|
|
const optionalMembers = Math.max(
|
|
tournament.maxTeamMemberCount - ownTeamMembers.length - missingMembers,
|
|
0,
|
|
);
|
|
|
|
const showDeleteMemberSection =
|
|
(!ownTeamCheckedIn && ownTeamMembers.length > 1) ||
|
|
(ownTeamCheckedIn &&
|
|
ownTeamMembers.length > TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL);
|
|
|
|
const playersAvailableToDirectlyAdd = (() => {
|
|
return data!.trustedPlayers.filter((user) => {
|
|
return tournament.ctx.teams.every((team) =>
|
|
team.members.every((member) => member.userId !== user.id),
|
|
);
|
|
});
|
|
})();
|
|
|
|
const teamIsFull = ownTeamMembers.length >= tournament.maxTeamMemberCount;
|
|
|
|
return (
|
|
<div>
|
|
<h3 className="tournament__section-header">
|
|
2. {t("tournament:pre.roster.header")}
|
|
</h3>
|
|
<section className="tournament__section stack lg items-center">
|
|
{playersAvailableToDirectlyAdd.length > 0 && !teamIsFull ? (
|
|
<>
|
|
<DirectlyAddPlayerSelect players={playersAvailableToDirectlyAdd} />
|
|
<Divider className="text-uppercase">{t("common:or")}</Divider>
|
|
</>
|
|
) : null}
|
|
{!teamIsFull ? (
|
|
<div className="stack md items-center">
|
|
<div className="text-center text-sm">
|
|
{t("tournament:actions.shareLink", { inviteLink })}
|
|
</div>
|
|
<div>
|
|
<Button
|
|
size="tiny"
|
|
onClick={() => copyToClipboard(inviteLink)}
|
|
variant="outlined"
|
|
>
|
|
{t("common:actions.copyToClipboard")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
<div className="stack lg horizontal mt-2 flex-wrap justify-center">
|
|
{ownTeamMembers.map((member, i) => {
|
|
return (
|
|
<div
|
|
key={member.userId}
|
|
className="stack sm items-center text-sm"
|
|
data-testid={`member-num-${i + 1}`}
|
|
>
|
|
<Avatar size="xsm" user={member} />
|
|
{member.discordName}
|
|
</div>
|
|
);
|
|
})}
|
|
{new Array(missingMembers).fill(null).map((_, i) => {
|
|
return (
|
|
<div key={i} className="tournament__missing-player">
|
|
?
|
|
</div>
|
|
);
|
|
})}
|
|
{new Array(optionalMembers).fill(null).map((_, i) => {
|
|
return (
|
|
<div
|
|
key={i}
|
|
className="tournament__missing-player tournament__missing-player__optional"
|
|
>
|
|
?
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
{showDeleteMemberSection ? (
|
|
<DeleteMember members={ownTeamMembers} />
|
|
) : null}
|
|
</section>
|
|
<div className="tournament__section__warning">
|
|
{t("tournament:pre.roster.footer", {
|
|
atLeastCount: TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL,
|
|
maxCount: tournament.maxTeamMemberCount,
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DirectlyAddPlayerSelect({ players }: { players: TrustedPlayer[] }) {
|
|
const { t } = useTranslation(["tournament", "common"]);
|
|
const fetcher = useFetcher();
|
|
const id = React.useId();
|
|
|
|
return (
|
|
<fetcher.Form method="post" className="stack horizontal sm items-end">
|
|
<div>
|
|
<Label htmlFor={id}>
|
|
{t("tournament:pre.roster.addTrusted.header")}
|
|
</Label>
|
|
<select id={id} name="userId">
|
|
{players.map((player) => {
|
|
return (
|
|
<option key={player.id} value={player.id}>
|
|
{discordFullName(player)}
|
|
</option>
|
|
);
|
|
})}
|
|
</select>
|
|
</div>
|
|
<SubmitButton
|
|
_action="ADD_PLAYER"
|
|
state={fetcher.state}
|
|
testId="add-player-button"
|
|
>
|
|
{t("common:actions.add")}
|
|
</SubmitButton>
|
|
</fetcher.Form>
|
|
);
|
|
}
|
|
|
|
function DeleteMember({ members }: { members: TournamentDataTeam["members"] }) {
|
|
const { t } = useTranslation(["tournament", "common"]);
|
|
const id = React.useId();
|
|
const fetcher = useFetcher();
|
|
const [expanded, setExpanded] = React.useState(false);
|
|
|
|
if (!expanded) {
|
|
return (
|
|
<Button
|
|
size="tiny"
|
|
variant="minimal-destructive"
|
|
onClick={() => setExpanded(true)}
|
|
>
|
|
{t("tournament:pre.roster.delete.button")}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<fetcher.Form method="post">
|
|
<Label htmlFor={id}>{t("tournament:pre.roster.delete.header")}</Label>
|
|
<div className="stack md horizontal">
|
|
<select name="userId" id={id}>
|
|
{members
|
|
.filter((member) => !member.isOwner)
|
|
.map((member) => (
|
|
<option key={member.userId} value={member.userId}>
|
|
{member.discordName}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<SubmitButton
|
|
state={fetcher.state}
|
|
_action="DELETE_TEAM_MEMBER"
|
|
variant="minimal-destructive"
|
|
>
|
|
{t("common:actions.delete")}
|
|
</SubmitButton>
|
|
</div>
|
|
</fetcher.Form>
|
|
);
|
|
}
|
|
|
|
// TODO: when "Can't pick stage more than 2 times" highlight those selects in red
|
|
// TODO: useBlocker to prevent leaving page if made changes without saving
|
|
function CounterPickMapPoolPicker() {
|
|
const { t } = useTranslation(["common", "game-misc", "tournament"]);
|
|
const tournament = useTournament();
|
|
const fetcher = useFetcher();
|
|
|
|
const { counterpickMaps, handleCounterpickMapPoolSelect } =
|
|
useSelectCounterpickMapPoolState();
|
|
|
|
const counterPickMapPool = new MapPool(
|
|
Object.entries(counterpickMaps).flatMap(([mode, stages]) => {
|
|
return stages
|
|
.filter((stageId) => stageId !== null)
|
|
.map((stageId) => {
|
|
return {
|
|
mode: mode as RankedModeShort,
|
|
stageId: stageId as StageId,
|
|
};
|
|
});
|
|
}),
|
|
);
|
|
|
|
const isOneModeTournamentOf =
|
|
tournament.modesIncluded.length === 1 ? tournament.modesIncluded[0] : null;
|
|
|
|
return (
|
|
<div>
|
|
<h3 className="tournament__section-header">
|
|
3. {t("tournament:pre.pool.header")}
|
|
</h3>
|
|
<section className="tournament__section">
|
|
<fetcher.Form
|
|
method="post"
|
|
className="stack md tournament__section-centered"
|
|
>
|
|
<input
|
|
type="hidden"
|
|
name="mapPool"
|
|
value={counterPickMapPool.serialized}
|
|
/>
|
|
{rankedModesShort
|
|
.filter(
|
|
(mode) =>
|
|
!isOneModeTournamentOf || isOneModeTournamentOf === mode,
|
|
)
|
|
.map((mode) => {
|
|
const tiebreakerStageId = tournament.ctx.tieBreakerMapPool.find(
|
|
(stage) => stage.mode === mode,
|
|
)?.stageId;
|
|
|
|
return (
|
|
<div key={mode} className="stack md">
|
|
<div className="stack sm">
|
|
<div className="stack horizontal sm items-center font-bold">
|
|
<Image
|
|
path={modeImageUrl(mode)}
|
|
width={32}
|
|
height={32}
|
|
alt=""
|
|
/>
|
|
{t(`game-misc:MODE_LONG_${mode}`)}
|
|
</div>
|
|
{typeof tiebreakerStageId === "number" ? (
|
|
<div className="text-xs text-lighter">
|
|
{t("tournament:pre.pool.tiebreaker", {
|
|
stage: t(`game-misc:STAGE_${tiebreakerStageId}`),
|
|
})}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
{new Array(
|
|
isOneModeTournamentOf
|
|
? TOURNAMENT.COUNTERPICK_ONE_MODE_TOURNAMENT_MAPS_PER_MODE
|
|
: TOURNAMENT.COUNTERPICK_MAPS_PER_MODE,
|
|
)
|
|
.fill(null)
|
|
.map((_, i) => {
|
|
return (
|
|
<div
|
|
key={i}
|
|
className="tournament__section__map-select-row"
|
|
>
|
|
{t("tournament:pre.pool.pick", { number: i + 1 })}
|
|
<select
|
|
value={counterpickMaps[mode][i] ?? undefined}
|
|
onChange={handleCounterpickMapPoolSelect(mode, i)}
|
|
data-testid={`counterpick-map-pool-${mode}-num-${
|
|
i + 1
|
|
}`}
|
|
>
|
|
<option value=""></option>
|
|
{stageIds.map((stageId) => {
|
|
const isBanned =
|
|
BANNED_MAPS[mode].includes(stageId);
|
|
|
|
const isTiebreaker =
|
|
stageId === tiebreakerStageId;
|
|
|
|
return (
|
|
<option key={stageId} value={stageId}>
|
|
{t(`game-misc:STAGE_${stageId}`)}{" "}
|
|
{isTiebreaker
|
|
? `(${t(
|
|
"tournament:pre.pool.tiebreaker.short",
|
|
)})`
|
|
: isBanned
|
|
? `(${t("tournament:pre.pool.banned")})`
|
|
: ""}
|
|
</option>
|
|
);
|
|
})}
|
|
</select>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
})}
|
|
{validateCounterPickMapPool(
|
|
counterPickMapPool,
|
|
isOneModeTournamentOf,
|
|
tournament.ctx.tieBreakerMapPool,
|
|
) === "VALID" ? (
|
|
<SubmitButton
|
|
_action="UPDATE_MAP_POOL"
|
|
state={fetcher.state}
|
|
className="self-center mt-4"
|
|
testId="save-map-list-button"
|
|
>
|
|
{t("common:actions.save")}
|
|
</SubmitButton>
|
|
) : (
|
|
<MapPoolValidationStatusMessage
|
|
status={validateCounterPickMapPool(
|
|
counterPickMapPool,
|
|
isOneModeTournamentOf,
|
|
tournament.ctx.tieBreakerMapPool,
|
|
)}
|
|
/>
|
|
)}
|
|
</fetcher.Form>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MapPoolValidationStatusMessage({
|
|
status,
|
|
}: {
|
|
status: CounterPickValidationStatus;
|
|
}) {
|
|
const { t } = useTranslation(["common"]);
|
|
|
|
if (
|
|
status !== "TOO_MUCH_STAGE_REPEAT" &&
|
|
status !== "STAGE_REPEAT_IN_SAME_MODE" &&
|
|
status !== "INCLUDES_BANNED" &&
|
|
status !== "INCLUDES_TIEBREAKER"
|
|
)
|
|
return null;
|
|
|
|
return (
|
|
<div className="mt-4">
|
|
<Alert alertClassName="w-max" variation="WARNING" tiny>
|
|
{t(`common:maps.validation.${status}`, {
|
|
maxStageRepeat: TOURNAMENT.COUNTERPICK_MAX_STAGE_REPEAT,
|
|
})}
|
|
</Alert>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type CounterPickValidationStatus =
|
|
| "PICKING"
|
|
| "VALID"
|
|
| "TOO_MUCH_STAGE_REPEAT"
|
|
| "STAGE_REPEAT_IN_SAME_MODE"
|
|
| "INCLUDES_BANNED"
|
|
| "INCLUDES_TIEBREAKER";
|
|
|
|
function validateCounterPickMapPool(
|
|
mapPool: MapPool,
|
|
isOneModeOnlyTournamentFor: ModeShort | null,
|
|
tieBreakerMapPool: TournamentData["ctx"]["tieBreakerMapPool"],
|
|
): CounterPickValidationStatus {
|
|
const stageCounts = new Map<StageId, number>();
|
|
for (const stageId of mapPool.stages) {
|
|
if (!stageCounts.has(stageId)) {
|
|
stageCounts.set(stageId, 0);
|
|
}
|
|
|
|
if (
|
|
stageCounts.get(stageId)! >= TOURNAMENT.COUNTERPICK_MAX_STAGE_REPEAT ||
|
|
(isOneModeOnlyTournamentFor && stageCounts.get(stageId)! >= 1)
|
|
) {
|
|
return "TOO_MUCH_STAGE_REPEAT";
|
|
}
|
|
|
|
stageCounts.set(stageId, stageCounts.get(stageId)! + 1);
|
|
}
|
|
|
|
if (
|
|
new MapPool(mapPool.serialized).stageModePairs.length !==
|
|
mapPool.stageModePairs.length
|
|
) {
|
|
return "STAGE_REPEAT_IN_SAME_MODE";
|
|
}
|
|
|
|
if (
|
|
mapPool.stageModePairs.some((pair) =>
|
|
BANNED_MAPS[pair.mode].includes(pair.stageId),
|
|
)
|
|
) {
|
|
return "INCLUDES_BANNED";
|
|
}
|
|
|
|
if (
|
|
mapPool.stageModePairs.some((pair) =>
|
|
tieBreakerMapPool.some(
|
|
(stage) => stage.mode === pair.mode && stage.stageId === pair.stageId,
|
|
),
|
|
)
|
|
) {
|
|
return "INCLUDES_TIEBREAKER";
|
|
}
|
|
|
|
if (
|
|
!isOneModeOnlyTournamentFor &&
|
|
(mapPool.parsed.SZ.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE ||
|
|
mapPool.parsed.TC.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE ||
|
|
mapPool.parsed.RM.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE ||
|
|
mapPool.parsed.CB.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE)
|
|
) {
|
|
return "PICKING";
|
|
}
|
|
|
|
if (
|
|
isOneModeOnlyTournamentFor &&
|
|
mapPool.parsed[isOneModeOnlyTournamentFor].length !==
|
|
TOURNAMENT.COUNTERPICK_ONE_MODE_TOURNAMENT_MAPS_PER_MODE
|
|
) {
|
|
return "PICKING";
|
|
}
|
|
|
|
return "VALID";
|
|
}
|