import { Form, Link, useFetcher, useLoaderData } from "@remix-run/react";
import clsx from "clsx";
import Compressor from "compressorjs";
import Markdown from "markdown-to-jsx";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useCopyToClipboard } from "react-use";
import { Alert } from "~/components/Alert";
import { Avatar } from "~/components/Avatar";
import { Divider } from "~/components/Divider";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import {
SendouTab,
SendouTabList,
SendouTabPanel,
SendouTabs,
} from "~/components/elements/Tabs";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { FriendCodeInput } from "~/components/FriendCodeInput";
import { Image, ModeImage } from "~/components/Image";
import { Input } from "~/components/Input";
import { CheckmarkIcon } from "~/components/icons/Checkmark";
import { ClockIcon } from "~/components/icons/Clock";
import { CrossIcon } from "~/components/icons/Cross";
import { DiscordIcon } from "~/components/icons/Discord";
import { TrashIcon } from "~/components/icons/Trash";
import { UserIcon } from "~/components/icons/User";
import { Label } from "~/components/Label";
import { containerClassName } from "~/components/Main";
import { MapPoolStages } from "~/components/MapPoolSelector";
import { Section } from "~/components/Section";
import { SubmitButton } from "~/components/SubmitButton";
import TimePopover from "~/components/TimePopover";
import { useUser } from "~/features/auth/core/user";
import { imgTypeToDimensions } from "~/features/img-upload/upload-constants";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { ModeMapPoolPicker } from "~/features/sendouq-settings/components/ModeMapPoolPicker";
import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
import { useAutoRerender } from "~/hooks/useAutoRerender";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import { modesShort, rankedModesShort } from "~/modules/in-game-lists/modes";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import {
LOG_IN_URL,
mapsPageWithMapPool,
navIconUrl,
SENDOU_INK_BASE_URL,
SENDOU_INK_DISCORD_URL,
tournamentJoinPage,
tournamentOrganizationPage,
tournamentSubsPage,
userEditProfilePage,
userPage,
} from "~/utils/urls";
import { AlertIcon } from "../../../components/icons/Alert";
import { action } from "../actions/to.$id.register.server";
import type { TournamentRegisterPageLoader } from "../loaders/to.$id.register.server";
import { loader } from "../loaders/to.$id.register.server";
import { TOURNAMENT } from "../tournament-constants";
import {
type CounterPickValidationStatus,
validateCounterPickMapPool,
} from "../tournament-utils";
import { useTournament } from "./to.$id";
export { loader, action };
export default function TournamentRegisterPage() {
const isMounted = useIsMounted();
const tournament = useTournament();
const startsAtEvenHour = tournament.ctx.startTime.getMinutes() === 0;
return (
{tournament.ctx.name}
{tournament.ctx.organization ? (
{tournament.ctx.organization.name}
) : (
{" "}
{tournament.ctx.author.username}
)}
{!tournament.isLeagueSignup ? (
{" "}
{isMounted ? (
) : null}
) : null}
{tournament.ranked ? (
Ranked
) : (
Unranked
)}
{tournament.modesIncluded.map((mode) => (
))}
);
}
const TABS = ["description", "rules", "register"] as const;
type RegisterPageTab = (typeof TABS)[number];
function TournamentRegisterInfoTabs() {
const user = useUser();
const tournament = useTournament();
const { t } = useTranslation(["tournament"]);
const teamMemberOf = tournament.teamMemberOfByUser(user);
const teamOwned = tournament.ownedTeamByUser(user);
const isRegularMemberOfATeam = teamMemberOf && !teamOwned;
const defaultTab = (): RegisterPageTab => {
if (tournament.hasStarted || !teamOwned) return "description";
return "register";
};
const [tabKey, setTabKey] = useSearchParamState({
defaultValue: defaultTab(),
name: "tab",
revive: (val) =>
TABS.includes(val as RegisterPageTab) ? (val as RegisterPageTab) : null,
});
const showAddIGNAlert =
tournament.ctx.settings.requireInGameNames &&
!teamOwned &&
user &&
!user?.inGameName;
return (
setTabKey(key as RegisterPageTab)}
>
Description
{tournament.ctx.rules ? (
Rules
) : null}
{!tournament.hasStarted ? (
Register
) : null}
{tournament.ctx.discordUrl ? (
}
>
Join the Discord
) : null}
{tournament.ctx.description ?? ""}
{tournament.ctx.rules ? (
{tournament.ctx.rules ?? ""}
) : null}
{!tournament.hasStarted ? (
{isRegularMemberOfATeam ? (
{t("tournament:pre.inATeam")}
{teamMemberOf && teamMemberOf.checkIns.length === 0 ? (
Leave the team
) : null}
) : showAddIGNAlert ? (
This tournament requires you to have an in-game name set{" "}
Edit profile
) : (
)}
{user &&
!tournament.teamMemberOfByUser(user) &&
tournament.canAddNewSubPost &&
!showAddIGNAlert &&
!tournament.hasStarted ? (
{t("tournament:pre.sub.prompt")}
) : null}
) : null}
);
}
function PleaseLogIn() {
const { t } = useTranslation(["tournament"]);
return (
);
}
function RegistrationForms() {
const data = useLoaderData();
const user = useUser();
const tournament = useTournament();
const ownTeam = tournament.ownedTeamByUser(user);
const ownTeamCheckedIn = Boolean(ownTeam && ownTeam.checkIns.length > 0);
if (!user && !tournament.isInvitational) {
return ;
}
const showRegistrationProgress = () => {
if (ownTeam) return true;
return !tournament.isInvitational;
};
const showRegisterNewTeam = () => {
if (ownTeam) return true;
if (tournament.isInvitational) return false;
if (!tournament.registrationOpen) return false;
return !tournament.regularCheckInHasEnded;
};
return (
{showRegistrationProgress() ? (
) : (
This tournament is invitational. Tournament organizer adds all teams.
)}
{showRegisterNewTeam() ? (
<>
{user?.friendCode ? (
) : null}
>
) : null}
{tournament.isLeagueSignup ?
: null}
{ownTeam ? (
<>
{tournament.teamsPrePickMaps ?
: null}
>
) : null}
);
}
function RegistrationProgress({
checkedIn,
name,
members,
mapPool,
}: {
checkedIn?: boolean;
name?: string;
members?: unknown[];
mapPool?: unknown[];
}) {
const { i18n, t } = useTranslation(["tournament"]);
const tournament = useTournament();
const isMounted = useIsMounted();
const completedIfTruthy = (condition: unknown) =>
condition ? "completed" : "incomplete";
const steps = [
{
name: t("tournament:pre.steps.name"),
status: completedIfTruthy(name),
},
{
name: t("tournament:pre.steps.roster"),
status: completedIfTruthy(
members && members.length >= tournament.minMembersPerTeam,
),
},
tournament.teamsPrePickMaps
? {
name: t("tournament:pre.steps.pool"),
status: completedIfTruthy(mapPool && mapPool.length > 0),
}
: null,
!tournament.isLeagueSignup
? {
name: t("tournament:pre.steps.check-in"),
status: completedIfTruthy(checkedIn),
}
: null,
tournament.isLeagueSignup
? {
name: "Google Sheet",
status: "notice" as const,
}
: null,
].filter((step) => step !== null);
const regClosesBeforeStart =
tournament.registrationClosesAt.getTime() !==
tournament.ctx.startTime.getTime();
const registrationClosesAtString = isMounted
? (tournament.isLeagueSignup
? tournament.ctx.startTime
: tournament.registrationClosesAt
).toLocaleTimeString(i18n.language, {
minute: "numeric",
hour: "numeric",
day: "2-digit",
month: "2-digit",
})
: "";
return (
{t("tournament:pre.steps.header")}
{steps.map((step, i) => {
return (
{step.name}
{step.status === "completed" ? (
) : step.status === "notice" ? (
) : (
)}
);
})}
{!tournament.isLeagueSignup ? (
step.status === "incomplete").length === 1
}
status={
tournament.regularCheckInIsOpen
? "OPEN"
: tournament.regularCheckInHasEnded
? "OVER"
: "UPCOMING"
}
startDate={tournament.regularCheckInStartsAt}
endDate={tournament.regularCheckInEndsAt}
checkedIn={checkedIn}
/>
) : null}
{regClosesBeforeStart || tournament.isLeagueSignup ? (
Registration closes at {registrationClosesAtString}
) : (
t("tournament:pre.footer")
)}
);
}
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 (
{t("tournament:pre.checkIn.range", {
start: checkInStartsString,
finish: checkInEndsString,
})}
);
}
if (checkedIn) {
return (
{t("tournament:pre.checkIn.checkedIn")}
);
}
if (status === "OVER") {
return (
{t("tournament:pre.checkIn.over")}
);
}
if (!canCheckIn) {
return (
{t("tournament:pre.checkIn.button")}
}
>
{t("tournament:pre.checkIn.cant")}
);
}
return (
{t("tournament:pre.checkIn.button")}
);
}
function TeamInfo({
ownTeam,
canUnregister,
}: {
ownTeam?: TournamentDataTeam | null;
canUnregister: boolean;
}) {
const data = useLoaderData();
const { t } = useTranslation(["tournament", "common"]);
const fetcher = useFetcher();
const tournament = useTournament();
const [teamName, setTeamName] = React.useState(ownTeam?.name ?? "");
const user = useUser();
const ref = React.useRef(null);
const [signUpWithTeamId, setSignUpWithTeamId] = React.useState(
() => tournament.ownedTeamByUser(user)?.team?.id ?? null,
);
const [uploadedAvatar, setUploadedAvatar] = React.useState(null);
const handleSignUpWithTeamChange = (teamId: number | null) => {
if (!teamId) {
setSignUpWithTeamId(null);
} else {
setSignUpWithTeamId(teamId);
const teamName = data?.teams.find((team) => team.id === teamId)?.name;
invariant(teamName, "team name should exist");
setTeamName(teamName);
}
};
const handleSubmit = () => {
const formData = new FormData(ref.current!);
if (uploadedAvatar) {
// replace with the compressed version
formData.delete("img");
formData.append("img", uploadedAvatar, uploadedAvatar.name);
}
fetcher.submit(formData, {
encType: uploadedAvatar ? "multipart/form-data" : undefined,
method: "post",
});
};
const submitButtonDisabled = () => {
if (fetcher.state !== "idle") return true;
return false;
};
const avatarUrl = (() => {
if (signUpWithTeamId) {
const teamToSignUpWith = data?.teams.find(
(team) => team.id === signUpWithTeamId,
);
return teamToSignUpWith?.logoUrl;
}
if (uploadedAvatar) return URL.createObjectURL(uploadedAvatar);
return ownTeam?.pickupAvatarUrl;
})();
const canEditAvatar =
tournament.registrationOpen &&
!signUpWithTeamId &&
uploadedAvatar &&
!ownTeam?.pickupAvatarUrl;
const canDeleteAvatar = ownTeam?.pickupAvatarUrl;
return (
2. {t("tournament:pre.info.header")}
{canUnregister &&
tournament.isLeagueSignup &&
!tournament.registrationOpen ? (
{t("tournament:pre.info.unregister")}
}
>
Unregistration from a league after the registration has ended is
handled by the organizers
) : canUnregister ? (
{t("tournament:pre.info.unregister")}
) : null}
);
}
const logoDimensions = imgTypeToDimensions["team-pfp"];
function TournamentLogoUpload({
onChange,
}: {
onChange: (file: File | null) => void;
}) {
return (
{
const uploadedFile = e.target.files?.[0];
if (!uploadedFile) {
onChange(null);
return;
}
new Compressor(uploadedFile, {
height: logoDimensions.height,
width: logoDimensions.width,
maxHeight: logoDimensions.height,
maxWidth: logoDimensions.width,
// 0.5MB
convertSize: 500_000,
resize: "cover",
success(result) {
const file = new File([result], "img.webp", {
type: "image/webp",
});
onChange(file);
},
error(err) {
logger.error(err.message);
},
});
}}
/>
);
}
function FriendCode() {
const user = useUser();
return (
1. Friend code
{user?.friendCode ? (
) : null}
);
}
function GoogleFormsLink() {
return (
Additional Requirement: Google Form
Answer to additional question about your team's preferred match time and
info to help with seeding
);
}
function FillRoster({
ownTeam,
ownTeamCheckedIn,
}: {
ownTeam: TournamentDataTeam;
ownTeamCheckedIn: boolean;
}) {
const data = useLoaderData();
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.minMembersPerTeam - ownTeamMembers.length,
0,
);
const optionalMembers = Math.max(
tournament.maxMembersPerTeam - ownTeamMembers.length - missingMembers,
0,
);
const showDeleteMemberSection =
(!ownTeamCheckedIn && ownTeamMembers.length > 1) ||
(ownTeamCheckedIn && ownTeamMembers.length > tournament.minMembersPerTeam);
const playersAvailableToDirectlyAdd = (() => {
return (data!.trusterPlayers?.trusters ?? []).filter((user) => {
const isNotInTeam = tournament.ctx.teams.every((team) =>
team.members.every((member) => member.userId !== user.id),
);
const hasInGameNameIfNeeded =
!tournament.ctx.settings.requireInGameNames || user.inGameName;
return isNotInTeam && hasInGameNameIfNeeded;
});
})();
const teamIsFull = ownTeamMembers.length >= tournament.maxMembersPerTeam;
const canAddMembers = !teamIsFull && tournament.registrationOpen;
return (
3. {t("tournament:pre.roster.header")}
{playersAvailableToDirectlyAdd.length > 0 && canAddMembers ? (
<>
{t("common:or")}
>
) : null}
{canAddMembers ? (
{t("tournament:actions.shareLink", { inviteLink })}
copyToClipboard(inviteLink)}
variant="outlined"
>
{t("common:actions.copyToClipboard")}
) : null}
{ownTeamMembers.map((member, i) => {
return (
{tournament.ctx.settings.requireInGameNames ? (
{member.inGameName ?? member.username}
{member.inGameName ? (
{member.username}
) : null}
) : (
{member.username}
)}
);
})}
{new Array(missingMembers).fill(null).map((_, i) => {
return (
?
);
})}
{new Array(optionalMembers).fill(null).map((_, i) => {
return (
?
);
})}
{showDeleteMemberSection ? (
) : null}
{tournament.ctx.settings.requireInGameNames ? (
Note that you are expected to use the in-game names as listed above.
Playing in the event with a different name or using the alias feature
might result in disqualification.
) : (
// TODO: proper English for 1v1 "At least 1 members are required to participate. Max roster size is 1"
{t("tournament:pre.roster.footer", {
atLeastCount: tournament.minMembersPerTeam,
maxCount: tournament.maxMembersPerTeam,
})}
)}
);
}
function DirectlyAddPlayerSelect({
players,
teams,
}: {
players: { id: number; username: string; teamId?: number }[];
teams: { id: number; name: string }[];
}) {
const { t } = useTranslation(["tournament", "common"]);
const fetcher = useFetcher();
const id = React.useId();
const othersOptions = players
.filter((player) => !player.teamId)
.map((player) => {
return (
{player.username}
);
});
return (
{t("tournament:pre.roster.addTrusted.header")}
{teams.map((team) => {
return (
{players
.filter((player) => player.teamId === team.id)
.map((player) => {
return (
{player.username}
);
})}
);
})}
{teams && teams.length > 0 ? (
{othersOptions}
) : (
othersOptions
)}
{t("common:actions.add")}
);
}
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 (
setExpanded(true)}
>
{t("tournament:pre.roster.delete.button")}
);
}
return (
{t("tournament:pre.roster.delete.header")}
{members
.filter((member) => !member.isOwner)
.map((member) => (
{member.username}
))}
{t("common:actions.delete")}
);
}
// 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 data = useLoaderData();
const [counterPickMaps, setCounterPickMaps] = React.useState(
data?.mapPool ?? [],
);
const counterPickMapPool = new MapPool(counterPickMaps);
const isOneModeTournamentOf =
tournament.modesIncluded.length === 1 ? tournament.modesIncluded[0] : null;
return (
4. {t("tournament:pre.pool.header")}
{rankedModesShort
.filter(
(mode) =>
!isOneModeTournamentOf || isOneModeTournamentOf === mode,
)
.map((mode) => {
return (
stage.mode === mode,
)?.stageId
}
pool={
counterPickMaps
.filter((m) => m.mode === mode)
.map((m) => m.stageId) ?? []
}
onChange={(stageIds) =>
setCounterPickMaps([
...counterPickMaps.filter((m) => m.mode !== mode),
...stageIds.map((stageId) => ({ mode, stageId })),
])
}
/>
);
})}
{validateCounterPickMapPool(
counterPickMapPool,
isOneModeTournamentOf,
tournament.ctx.tieBreakerMapPool,
) === "VALID" ? (
{t("common:actions.save")}
) : (
)}
);
}
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 (
{t(`common:maps.validation.${status}`, {
maxStageRepeat: TOURNAMENT.COUNTERPICK_MAX_STAGE_REPEAT,
})}
);
}
function TOPickedMapPoolInfo() {
const { t } = useTranslation(["calendar"]);
const tournament = useTournament();
if (tournament.ctx.toSetMapPool.length === 0) return null;
const mapPool = new MapPool(tournament.ctx.toSetMapPool);
return (
{t("calendar:createMapList")}
);
}
function TiebreakerMapPoolInfo() {
const { t } = useTranslation(["game-misc"]);
const tournament = useTournament();
if (tournament.ctx.tieBreakerMapPool.length === 0) return null;
return (
Tiebreaker map pool:{" "}
{tournament.ctx.tieBreakerMapPool
.sort((a, b) => modesShort.indexOf(a.mode) - modesShort.indexOf(b.mode))
.map(
(map) =>
`${t(`game-misc:MODE_SHORT_${map.mode}`)} ${t(`game-misc:STAGE_${map.stageId}`)}`,
)
.join(", ")}
);
}