diff --git a/app/components/tournament/InfoBanner.tsx b/app/components/tournament/InfoBanner.tsx
index 1e667c75a..f294b0b54 100644
--- a/app/components/tournament/InfoBanner.tsx
+++ b/app/components/tournament/InfoBanner.tsx
@@ -122,12 +122,13 @@ function InfoBannerActionButton() {
const tournamentHasConcluded = new Date(data.startTime) < now;
if (tournamentHasConcluded) return null;
- if (!user)
+ if (!user) {
return (
-
- Log in to register
-
+
);
+ }
const isAlreadyInATeamButNotCaptain = data.teams
.flatMap((team) => team.members)
diff --git a/app/routes/to/$organization.$tournament.tsx b/app/routes/to/$organization.$tournament.tsx
index 1eb8943ce..bf247efe9 100644
--- a/app/routes/to/$organization.$tournament.tsx
+++ b/app/routes/to/$organization.$tournament.tsx
@@ -54,9 +54,9 @@ export default function TournamentPage() {
const data = useLoaderData();
const location = useLocation();
- const displayNavLinks =
- !location.pathname.endsWith("register") &&
- !location.pathname.endsWith("manage-roster");
+ const displayNavLinks = ["register", "manage-roster", "join-team"].every(
+ (urlPart) => !location.pathname.endsWith(urlPart)
+ );
const navLinks = (() => {
const result: { code: string; text: string }[] = [
@@ -91,6 +91,7 @@ export default function TournamentPage() {
className="tournament__nav-link"
to={code}
data-cy={`${code}-nav-link`}
+ prefetch="intent"
>
{text}
diff --git a/app/routes/to/$organization.$tournament/join-team.tsx b/app/routes/to/$organization.$tournament/join-team.tsx
new file mode 100644
index 000000000..191f1be5e
--- /dev/null
+++ b/app/routes/to/$organization.$tournament/join-team.tsx
@@ -0,0 +1,162 @@
+import {
+ json,
+ useLoaderData,
+ redirect,
+ Form,
+ useNavigate,
+ useMatches,
+} from "remix";
+import type { LoaderFunction, LinksFunction } from "remix";
+import { findTournamentWithInviteCodes } from "~/services/tournament";
+import styles from "~/styles/tournament-join-team.css";
+import invariant from "tiny-invariant";
+import { getUser } from "~/utils";
+
+export const links: LinksFunction = () => {
+ return [{ rel: "stylesheet", href: styles }];
+};
+
+const INVITE_CODE_LENGTH = 36;
+
+type ResponseObject =
+ | { status: "NO_CODE" }
+ | { status: "TOO_SHORT" }
+ | { status: "LOG_IN" }
+ | { status: "ALREADY_JOINED"; teamName: string }
+ | { status: "INVALID" }
+ | { status: "OK"; teamName: string; inviterName: string };
+
+const typedJson = (args: ResponseObject) => json(args);
+
+export const loader: LoaderFunction = async ({ request, params, context }) => {
+ invariant(
+ typeof params.organization === "string",
+ "Expected params.organization to be string"
+ );
+ invariant(
+ typeof params.tournament === "string",
+ "Expected params.tournament to be string"
+ );
+
+ const inviteCode = new URL(request.url).searchParams.get("code");
+ if (!inviteCode) return typedJson({ status: "NO_CODE" });
+ if (inviteCode.length !== INVITE_CODE_LENGTH)
+ return typedJson({ status: "TOO_SHORT" });
+
+ const user = getUser(context);
+
+ if (!user) return typedJson({ status: "LOG_IN" });
+
+ const tournament = await findTournamentWithInviteCodes({
+ organizationNameForUrl: params.organization,
+ tournamentNameForUrl: params.tournament,
+ });
+
+ // TODO: handle inviting players mid-event
+ if (tournament.startTime < new Date()) {
+ return redirect(`/to/${params.organization}/${params.tournament}`);
+ }
+
+ const teamAlreadyMemberOf = tournament.teams.find((team) =>
+ team.members.some(({ member }) => member.id === user.id)
+ );
+
+ if (
+ teamAlreadyMemberOf?.members.some(
+ ({ member, captain }) => member.id === user.id && captain
+ )
+ ) {
+ return redirect(
+ `/to/${params.organization}/${params.tournament}/manage-roster`
+ );
+ }
+
+ // TODO: handle switching team
+ if (teamAlreadyMemberOf) {
+ return typedJson({
+ status: "ALREADY_JOINED",
+ teamName: teamAlreadyMemberOf.name,
+ });
+ }
+
+ const teamInvitedTo = tournament.teams.find(
+ (team) => team.inviteCode === inviteCode
+ );
+ if (!teamInvitedTo) return typedJson({ status: "INVALID" });
+
+ return typedJson({
+ status: "OK",
+ teamName: teamInvitedTo.name,
+ inviterName: teamInvitedTo.members.find(({ captain }) => captain)!.member
+ .discordName,
+ });
+};
+
+export default function JoinTeamPage() {
+ const data = useLoaderData();
+
+ return (
+
+
+
+ );
+}
+
+function Contents({ data }: { data: ResponseObject }) {
+ const navigate = useNavigate();
+ const [, parentRoute] = useMatches();
+
+ switch (data.status) {
+ case "NO_CODE":
+ return (
+ <>
+ No invite code provided in the URL. Please ask your captain to double
+ check the URL they gave you.
+ >
+ );
+ case "TOO_SHORT":
+ return (
+ <>
+ The code provided in the URL is too short. Please ask your captain to
+ double check the URL they gave you.
+ >
+ );
+ case "INVALID":
+ return (
+ <>
+ The code provided in the URL is invalid. Please ask your captain to
+ double check the URL they gave you.
+ >
+ );
+ case "LOG_IN":
+ return <>Please log in to join this team.>;
+ case "ALREADY_JOINED":
+ return (
+ <>You are already a member of {data.teamName} for this tournament.>
+ );
+ case "OK":
+ return (
+
+ {data.inviterName} invited you to join {data.teamName} for this
+ tournament. Accept invite?
+
+
+ );
+ default:
+ const exhaustive: never = data;
+ throw new Error(
+ `Unexpected join team status code: ${JSON.stringify(exhaustive)}`
+ );
+ }
+}
diff --git a/app/routes/to/$organization.$tournament/manage-roster.tsx b/app/routes/to/$organization.$tournament/manage-roster.tsx
index 2b816dbfb..7d4fe1631 100644
--- a/app/routes/to/$organization.$tournament/manage-roster.tsx
+++ b/app/routes/to/$organization.$tournament/manage-roster.tsx
@@ -22,7 +22,7 @@ export default function ManageRosterPage() {
React.useEffect(() => {
if (ownTeam) {
setUrlWithInviteCode(
- `${window.location.href.replace("/manage-roster", "")}?join=${
+ `${window.location.href.replace("manage-roster", "join-team")}?code=${
ownTeam.inviteCode
}`
);
diff --git a/app/services/tournament.ts b/app/services/tournament.ts
index 1baf91f52..d631b3147 100644
--- a/app/services/tournament.ts
+++ b/app/services/tournament.ts
@@ -105,11 +105,58 @@ function discordInviteToUrl(discordInvite: string) {
return `https://discord.com/invite/${discordInvite}`;
}
-function censorInviteCodeIfNotCaptain(
- inviteCode: string,
- members: { member: { id: number }; captain: boolean }[]
-) {
- return inviteCode;
+export async function findTournamentWithInviteCodes({
+ organizationNameForUrl,
+ tournamentNameForUrl,
+}: {
+ organizationNameForUrl: string;
+ tournamentNameForUrl: string;
+}) {
+ const tournaments = await db.tournament.findMany({
+ where: {
+ nameForUrl: tournamentNameForUrl.toLowerCase(),
+ },
+ select: {
+ startTime: true,
+ organizer: {
+ select: {
+ nameForUrl: true,
+ },
+ },
+ mapPool: {
+ select: {
+ mode: true,
+ name: true,
+ },
+ },
+ teams: {
+ select: {
+ name: true,
+ inviteCode: true,
+ members: {
+ select: {
+ captain: true,
+ member: {
+ select: {
+ discordName: true,
+ id: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ });
+
+ const result = tournaments.find(
+ (tournament) =>
+ tournament.organizer.nameForUrl === organizationNameForUrl.toLowerCase()
+ );
+
+ if (!result) throw json("Not Found", { status: 404 });
+
+ return result;
}
export function createTournamentTeam({
diff --git a/app/styles/global.css b/app/styles/global.css
index eef6b394c..8c7a698ec 100644
--- a/app/styles/global.css
+++ b/app/styles/global.css
@@ -118,8 +118,14 @@ input:not(.plain) {
outline: none;
}
+input:user-invalid {
+ border-color: transparent;
+ outline: 2px solid var(--theme-error);
+}
+
input:not(.plain):focus-within {
border-color: transparent;
+
/* TODO: rectangle on Safari */
outline: 2px solid var(--theme);
}
@@ -135,11 +141,6 @@ input:not(.plain).error {
outline: 2px solid var(--theme-error);
}
-input:user-invalid {
- border-color: transparent;
- outline: 2px solid var(--theme-error);
-}
-
label {
display: block;
color: var(--text-lighter);
@@ -154,10 +155,8 @@ fieldset {
select {
all: unset;
- padding-block: 3.5px;
- padding-inline: var(--s-3);
- border: 1px solid var(--border);
width: 90%;
+ border: 1px solid var(--border);
/* TODO: Get color from CSS var */
background-image: url('data:image/svg+xml;utf8, ');
@@ -167,11 +166,13 @@ select {
border-radius: var(--rounded);
cursor: pointer;
font-weight: 500;
+ padding-block: 3.5px;
+ padding-inline: var(--s-3);
}
select::selection {
- font-weight: bold;
overflow: hidden;
+ font-weight: bold;
}
.form-validation-error {
@@ -184,13 +185,13 @@ select::selection {
.alert {
display: flex;
align-items: center;
- gap: var(--s-2);
- border-radius: var(--rounded);
padding: var(--s-2-5);
background: url("/svg/background-pattern.svg");
background-color: var(--bg-lighter);
- font-weight: var(--bold);
+ border-radius: var(--rounded);
font-size: var(--fonts-sm);
+ font-weight: var(--bold);
+ gap: var(--s-2);
}
.alert > svg {
diff --git a/app/styles/reset.css b/app/styles/reset.css
index d7bc51e79..9804f3e07 100644
--- a/app/styles/reset.css
+++ b/app/styles/reset.css
@@ -6,18 +6,22 @@
*::after {
box-sizing: border-box;
}
+
* {
margin: 0;
overflow-wrap: break-word;
}
+
html,
body {
height: 100%;
}
+
body {
- line-height: 1.5;
-webkit-font-smoothing: antialiased;
+ line-height: 1.5;
}
+
img,
picture,
video,
@@ -26,13 +30,10 @@ svg {
display: block;
max-width: 100%;
}
+
input,
button,
textarea,
select {
font: inherit;
}
-#root,
-#__next {
- isolation: isolate;
-}
diff --git a/app/styles/tournament-join-team.css b/app/styles/tournament-join-team.css
new file mode 100644
index 000000000..e8dd2abdb
--- /dev/null
+++ b/app/styles/tournament-join-team.css
@@ -0,0 +1,10 @@
+.tournament__join-team {
+ padding-block-start: var(--s-4);
+}
+
+.tournament__join-team__buttons {
+ display: flex;
+ justify-content: center;
+ gap: var(--s-2);
+ padding-block-start: var(--s-4);
+}
diff --git a/app/styles/tournament-manage-roster.css b/app/styles/tournament-manage-roster.css
index 5b88867db..5ab80f692 100644
--- a/app/styles/tournament-manage-roster.css
+++ b/app/styles/tournament-manage-roster.css
@@ -1,15 +1,15 @@
.tournament__manage-roster {
display: flex;
+ width: 100%;
flex-direction: column;
gap: var(--s-8);
margin-block-start: var(--s-4);
- width: 100%;
}
.tournament__manage-roster__actions {
display: grid;
- grid-template-columns: 1fr 1fr;
width: 100%;
+ grid-template-columns: 1fr 1fr;
}
.tournament__manage-roster__actions__section {
diff --git a/app/styles/tournament.css b/app/styles/tournament.css
index 9fbf31683..f6051b850 100644
--- a/app/styles/tournament.css
+++ b/app/styles/tournament.css
@@ -1,6 +1,6 @@
.tournament__container {
- width: 100%;
display: flex;
+ width: 100%;
flex-direction: column;
align-items: center;
margin: 0 auto;
@@ -84,11 +84,11 @@
.info-banner__top-row__tournament-name {
border-color: var(--text);
border-inline-start: 1px solid;
+ color: var(--text);
font-size: var(--fonts-xl);
font-weight: var(--extra-bold);
padding-inline-start: var(--s-4);
text-decoration: none;
- color: var(--text);
}
.info-banner__bottom-row {
@@ -137,6 +137,7 @@
}
.info-banner__action-button {
+ all: unset;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -144,6 +145,7 @@
border-color: var(--text-transparent);
border-radius: var(--rounded);
color: inherit;
+ cursor: pointer;
font-size: var(--fonts-sm);
font-weight: var(--bold);
padding-block: var(--s-2);