From 0a56f72d8b8a35c1df0d9b79d610b51db9195276 Mon Sep 17 00:00:00 2001 From: "Kalle (Sendou)" <38327916+Sendouc@users.noreply.github.com> Date: Sat, 4 Dec 2021 14:00:28 +0200 Subject: [PATCH] Join team page UI initial --- app/components/tournament/InfoBanner.tsx | 9 +- app/routes/to/$organization.$tournament.tsx | 7 +- .../$organization.$tournament/join-team.tsx | 162 ++++++++++++++++++ .../manage-roster.tsx | 2 +- app/services/tournament.ts | 57 +++++- app/styles/global.css | 25 +-- app/styles/reset.css | 11 +- app/styles/tournament-join-team.css | 10 ++ app/styles/tournament-manage-roster.css | 4 +- app/styles/tournament.css | 6 +- 10 files changed, 259 insertions(+), 34 deletions(-) create mode 100644 app/routes/to/$organization.$tournament/join-team.tsx create mode 100644 app/styles/tournament-join-team.css 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);