mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-29 22:58:18 -05:00
Join team page UI initial
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
162
app/routes/to/$organization.$tournament/join-team.tsx
Normal file
162
app/routes/to/$organization.$tournament/join-team.tsx
Normal 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)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}`
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
10
app/styles/tournament-join-team.css
Normal file
10
app/styles/tournament-join-team.css
Normal 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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user