diff --git a/app/features/team/queries/addNewTeamMember.server.ts b/app/features/team/queries/addNewTeamMember.server.ts index b2b14351b..3b38d7316 100644 --- a/app/features/team/queries/addNewTeamMember.server.ts +++ b/app/features/team/queries/addNewTeamMember.server.ts @@ -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 }); } diff --git a/app/features/team/routes/t.$customUrl.join.tsx b/app/features/team/routes/t.$customUrl.join.tsx new file mode 100644 index 000000000..219fff8de --- /dev/null +++ b/app/features/team/routes/t.$customUrl.join.tsx @@ -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 ( +
+
+
+ {t(`team:validation.${validation}`, { teamName })} +
+ {validation === "VALID" ? ( + {t("common:actions.join")} + ) : null} +
+
+ ); +} diff --git a/app/features/team/team.css b/app/features/team/team.css index 28f8fc255..020594b3d 100644 --- a/app/features/team/team.css +++ b/app/features/team/team.css @@ -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; diff --git a/app/features/tournament/routes/to.$id.join.tsx b/app/features/tournament/routes/to.$id.join.tsx index 59ba34a50..f32751167 100644 --- a/app/features/tournament/routes/to.$id.join.tsx +++ b/app/features/tournament/routes/to.$id.join.tsx @@ -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)); diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 64ee6f24b..157c2d8cb 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -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", diff --git a/public/locales/en/team.json b/public/locales/en/team.json index 165dcb377..327dc035d 100644 --- a/public/locales/en/team.json +++ b/public/locales/en/team.json @@ -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}}?" } diff --git a/remix.config.js b/remix.config.js index 70f74d3ff..2d4b90237 100644 --- a/remix.config.js +++ b/remix.config.js @@ -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"); }); }, };