mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-29 22:58:18 -05:00
Team: Join page
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
141
app/features/team/routes/t.$customUrl.join.tsx
Normal file
141
app/features/team/routes/t.$customUrl.join.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}}?"
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user