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 (
{t("tournament:pre.logIn")}
); } 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}
{signUpWithTeamId ? ( ) : null}
{data && data.teams.length > 0 && tournament.registrationOpen ? (
) : null} {!signUpWithTeamId ? (
setTeamName(e.target.value)} readOnly={ !tournament.registrationOpen || Boolean(signUpWithTeamId) } />
) : ( )} {tournament.registrationOpen || avatarUrl ? (
{avatarUrl ? (
{canEditAvatar ? ( setUploadedAvatar(null)} > {t("common:actions.edit")} ) : null} {canDeleteAvatar ? ( ) : null}
) : ( )}
) : null}
{t("common:actions.save")}
); } 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 ? (
Is the friend code above wrong? Post a message on the{" "} sendou.ink Discord helpdesk {" "} to change it.
) : null}
); } function GoogleFormsLink() { return (

Additional Requirement: Google Form

Answer survey hosted on Google Forms
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 ( ); }); return (
{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("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(", ")}
); }