Join team page UI initial

This commit is contained in:
Kalle (Sendou)
2021-12-04 14:00:28 +02:00
parent b2e7c6b275
commit 0a56f72d8b
10 changed files with 259 additions and 34 deletions

View File

@@ -122,12 +122,13 @@ function InfoBannerActionButton() {
const tournamentHasConcluded = new Date(data.startTime) < now;
if (tournamentHasConcluded) return null;
if (!user)
if (!user) {
return (
<Link to="register" className="info-banner__action-button">
Log in to register
</Link>
<form action="/auth/discord" method="post">
<button className="info-banner__action-button">Log in to join</button>
</form>
);
}
const isAlreadyInATeamButNotCaptain = data.teams
.flatMap((team) => team.members)

View File

@@ -54,9 +54,9 @@ export default function TournamentPage() {
const data = useLoaderData<FindTournamentByNameForUrlI>();
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}
</NavLink>

View File

@@ -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<ResponseObject>();
return (
<div className="tournament__join-team">
<Contents data={data} />
</div>
);
}
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 (
<div>
{data.inviterName} invited you to join {data.teamName} for this
tournament. Accept invite?
<Form method="post">
<div className="tournament__join-team__buttons">
<button type="submit">Join</button>
<button
className="outlined"
type="button"
onClick={() => navigate(parentRoute.pathname)}
>
Don't join
</button>
</div>
</Form>
</div>
);
default:
const exhaustive: never = data;
throw new Error(
`Unexpected join team status code: ${JSON.stringify(exhaustive)}`
);
}
}

View File

@@ -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
}`
);

View File

@@ -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({

View File

@@ -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,<svg width="1rem" color="rgb(255 255 255 / 55%)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>');
@@ -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 {

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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 {

View File

@@ -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);