Team: Join page

This commit is contained in:
Kalle
2023-01-07 12:08:29 +02:00
parent c8e707488b
commit 2eca0b94c1
7 changed files with 165 additions and 2 deletions

View File

@@ -11,6 +11,12 @@ const stm = sql.prepare(/* sql */ `
"leftAt" = null
`);
export function addNewTeamMember(teamId: number, userId: number) {
export function addNewTeamMember({
teamId,
userId,
}: {
teamId: number;
userId: number;
}) {
stm.run({ teamId, userId });
}

View File

@@ -0,0 +1,141 @@
import type {
ActionFunction,
LinksFunction,
LoaderArgs,
} from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { INVITE_CODE_LENGTH } from "~/constants";
import { useTranslation } from "~/hooks/useTranslation";
import { requireUser } from "~/modules/auth";
import {
notFoundIfFalsy,
type SendouRouteHandle,
validate,
} from "~/utils/remix";
import { teamPage } from "~/utils/urls";
import { addNewTeamMember } from "../queries/addNewTeamMember.server";
import { findByIdentifier } from "../queries/findByIdentifier.server";
import { inviteCodeById } from "../queries/inviteCodeById.server";
import { teamParamsSchema } from "../team-schemas.server";
import type { DetailedTeam } from "../team-types";
import { isTeamFull, isTeamMember } from "../team-utils";
import styles from "../team.css";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
};
export const action: ActionFunction = async ({ request, params }) => {
const user = await requireUser(request);
const { customUrl } = teamParamsSchema.parse(params);
const team = notFoundIfFalsy(findByIdentifier(customUrl));
const inviteCode = new URL(request.url).searchParams.get("code") ?? "";
const realInviteCode = inviteCodeById(team.id)!;
validate(
validateInviteCode({
inviteCode,
realInviteCode,
team,
user,
}) === "VALID"
);
addNewTeamMember({ teamId: team.id, userId: user.id });
return redirect(teamPage(team.customUrl));
};
export const handle: SendouRouteHandle = {
i18n: ["team"],
// breadcrumb: () => ({
// imgPath: navIconUrl("object-damage-calculator"),
// href: OBJECT_DAMAGE_CALCULATOR_URL,
// type: "IMAGE",
// }),
};
export const loader = async ({ request, params }: LoaderArgs) => {
const user = await requireUser(request);
const { customUrl } = teamParamsSchema.parse(params);
const team = notFoundIfFalsy(findByIdentifier(customUrl));
const inviteCode = new URL(request.url).searchParams.get("code") ?? "";
const realInviteCode = inviteCodeById(team.id)!;
const validation = validateInviteCode({
inviteCode,
realInviteCode,
team,
user,
});
if (validation === "ALREADY_JOINED") {
throw redirect(teamPage(team.customUrl));
}
return { validation, teamName: team.name };
};
function validateInviteCode({
inviteCode,
realInviteCode,
team,
user,
}: {
inviteCode: string;
realInviteCode: string;
team: DetailedTeam;
user?: { id: number; team?: { name: string } };
}) {
if (inviteCode.length !== INVITE_CODE_LENGTH) {
return "SHORT_CODE";
}
if (inviteCode !== realInviteCode) {
return "INVITE_CODE_WRONG";
}
if (isTeamFull(team)) {
return "TEAM_FULL";
}
if (isTeamMember({ team, user })) {
return "ALREADY_JOINED";
}
if (user?.team) {
return "ALREADY_IN_DIFFERENT_TEAM";
}
return "VALID";
}
export default function JoinTeamPage() {
const { t } = useTranslation(["team", "common"]);
const { validation, teamName } = useLoaderData<{
// not sure why using typeof loader here results validation in being typed as "string"
validation:
| "SHORT_CODE"
| "INVITE_CODE_WRONG"
| "TEAM_FULL"
| "ALREADY_IN_DIFFERENT_TEAM"
| "VALID";
teamName: string;
}>();
return (
<Main>
<Form method="post" className="team__invite-container">
<div className="text-center">
{t(`team:validation.${validation}`, { teamName })}
</div>
{validation === "VALID" ? (
<SubmitButton size="big">{t("common:actions.join")}</SubmitButton>
) : null}
</Form>
</Main>
);
}

View File

@@ -211,6 +211,14 @@
width: 100%;
}
.team__invite-container {
margin-block-start: var(--s-14);
display: flex;
flex-direction: column;
gap: var(--s-12);
align-items: center;
}
@media screen and (min-width: 640px) {
.team__banner__flags {
display: flex;

View File

@@ -25,6 +25,7 @@ export const action: ActionFunction = async ({ request }) => {
const user = await requireUser(request);
const url = new URL(request.url);
const inviteCode = url.searchParams.get("code");
// xxx: don't throw here
invariant(inviteCode, "code is missing");
const leanTeam = notFoundIfFalsy(findByInviteCode(inviteCode));

View File

@@ -50,6 +50,7 @@
"actions.selectAll": "Select All",
"actions.search": "Search",
"actions.setBg": "Set background",
"actions.join": "Join",
"maps.createMapList": "Create map list",
"maps.halfSz": "50% SZ",

View File

@@ -22,5 +22,10 @@
"forms.errors.duplicateName": "There is already a team with this name",
"roster.teamFull": "Team is full",
"roster.inviteLink.header": "Share invite link to add members",
"roster.members.header": "Members"
"roster.members.header": "Members",
"validation.SHORT_CODE": "Invite code is not the right length. Did you copy the full URL?",
"validation.TEAM_FULL": "Team you are trying to join is full.",
"validation.INVITE_CODE_WRONG": "Invite code is wrong.",
"validation.ALREADY_IN_DIFFERENT_TEAM": "You are already in a different team. Leave it first before joining a new one.",
"validation.VALID": "Join {{teamName}}?"
}

View File

@@ -28,6 +28,7 @@ module.exports = {
"/t/:customUrl/roster",
"features/team/routes/t.$customUrl.roster.tsx"
);
route("/t/:customUrl/join", "features/team/routes/t.$customUrl.join.tsx");
});
},
};