diff --git a/app/db/tables.ts b/app/db/tables.ts index a76aa2c65..279abb7d7 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -702,6 +702,7 @@ export interface TournamentLFGGroup { createdAt: Generated; } +// xxx: duplicate state with tournament team member... keeping these up to date might be annoying export interface TournamentLFGGroupMember { groupId: number; tournamentId: number; diff --git a/app/features/tournament-lfg/TournamentLFGRepository.server.ts b/app/features/tournament-lfg/TournamentLFGRepository.server.ts index e202a2286..99062f99f 100644 --- a/app/features/tournament-lfg/TournamentLFGRepository.server.ts +++ b/app/features/tournament-lfg/TournamentLFGRepository.server.ts @@ -10,6 +10,39 @@ import { } from "~/utils/kysely.server"; import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql"; +type CreateGroupFromTeamArgs = { + tournamentId: number; + tournamentTeamId: number; + members: Array<{ userId: number; isOwner: boolean }>; +}; +export function createGroupFromTeam(args: CreateGroupFromTeamArgs) { + return db.transaction().execute(async (trx) => { + const createdGroup = await trx + .insertInto("TournamentLFGGroup") + .values({ + tournamentId: args.tournamentId, + tournamentTeamId: args.tournamentTeamId, + chatCode: shortNanoid(), + }) + .returning("id") + .executeTakeFirstOrThrow(); + + for (const member of args.members) { + await trx + .insertInto("TournamentLFGGroupMember") + .values({ + groupId: createdGroup.id, + tournamentId: args.tournamentId, + userId: member.userId, + role: member.isOwner ? "OWNER" : "REGULAR", + }) + .execute(); + } + + return createdGroup; + }); +} + type CreateGroupArgs = { tournamentId: number; userId: number; diff --git a/app/features/tournament-lfg/actions/to.$id.looking.server.ts b/app/features/tournament-lfg/actions/to.$id.looking.server.ts index 5520598ff..cbf27529f 100644 --- a/app/features/tournament-lfg/actions/to.$id.looking.server.ts +++ b/app/features/tournament-lfg/actions/to.$id.looking.server.ts @@ -39,15 +39,43 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { switch (data._action) { case "JOIN_QUEUE": { - // xxx: handle already in a team const existingGroup = await findOwnGroup(); if (existingGroup) return null; - await TournamentLFGRepository.createGroup({ + const tournament = await tournamentFromDBCached({ tournamentId, - userId: user.id, - isStayAsSub: data.stayAsSub ?? false, + user, }); + const team = tournament.teamMemberOfByUser(user); + + if (team) { + const groups = + await TournamentLFGRepository.findGroupsByTournamentId(tournamentId); + const teamAlreadyLinked = groups.some( + (g) => g.tournamentTeamId === team.id, + ); + if (teamAlreadyLinked) return null; + + const memberAlreadyInGroup = team.members.some((m) => + groups.some((g) => g.members.some((gm) => gm.id === m.userId)), + ); + if (memberAlreadyInGroup) return null; + + await TournamentLFGRepository.createGroupFromTeam({ + tournamentId, + tournamentTeamId: team.id, + members: team.members.map((m) => ({ + userId: m.userId, + isOwner: Boolean(m.isOwner), + })), + }); + } else { + await TournamentLFGRepository.createGroup({ + tournamentId, + userId: user.id, + isStayAsSub: data.stayAsSub ?? false, + }); + } break; } diff --git a/app/features/tournament-lfg/loaders/to.$id.looking.server.ts b/app/features/tournament-lfg/loaders/to.$id.looking.server.ts index bffc91b01..8b81b89f3 100644 --- a/app/features/tournament-lfg/loaders/to.$id.looking.server.ts +++ b/app/features/tournament-lfg/loaders/to.$id.looking.server.ts @@ -1,6 +1,7 @@ import type { LoaderFunctionArgs } from "react-router"; import type { Pronouns } from "~/db/tables"; import { getUser } from "~/features/auth/core/user.server"; +import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; import { parseParams } from "~/utils/remix.server"; import { idObject } from "~/utils/zod"; @@ -36,15 +37,75 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { ? await TournamentLFGRepository.allLikesByGroupId(ownGroup.id) : { given: [], received: [] }; + const ownTeam = await resolveOwnTeam({ + user, + tournamentId, + ownGroup, + groups, + }); + return { groups: otherGroups, ownGroup, + ownTeam, likes, lastUpdated: Date.now(), tournamentId, }; }; +async function resolveOwnTeam({ + user, + tournamentId, + ownGroup, + groups, +}: { + user: ReturnType; + tournamentId: number; + ownGroup: LFGGroup | null; + groups: LFGGroup[]; +}): Promise { + if (!user) return null; + if (ownGroup) return null; + + const tournament = await tournamentFromDBCached({ + tournamentId, + user, + }); + + const team = tournament.teamMemberOfByUser(user); + if (!team) return null; + + const teamAlreadyLinked = groups.some((g) => g.tournamentTeamId === team.id); + if (teamAlreadyLinked) return null; + + const members: LFGGroupMember[] = team.members.map((m) => ({ + id: m.userId, + username: m.username, + discordId: m.discordId, + discordAvatar: m.discordAvatar, + customUrl: m.customUrl, + languages: [], + vc: null, + pronouns: null, + role: m.isOwner ? "OWNER" : "REGULAR", + note: null, + isStayAsSub: false, + weapons: null, + chatNameColor: null, + plusTier: m.plusTier, + })); + + return { + id: -1, + tournamentTeamId: team.id, + teamName: team.name, + teamAvatarUrl: team.pickupAvatarUrl, + members, + usersRole: members.find((m) => m.id === user.id)?.role ?? null, + }; +} + function transformMembers( rawMembers: Awaited< ReturnType diff --git a/app/features/tournament-lfg/routes/to.$id.looking.tsx b/app/features/tournament-lfg/routes/to.$id.looking.tsx index 36035e446..d7cb640c2 100644 --- a/app/features/tournament-lfg/routes/to.$id.looking.tsx +++ b/app/features/tournament-lfg/routes/to.$id.looking.tsx @@ -1,5 +1,5 @@ import clsx from "clsx"; -import * as React from "react"; +import type * as React from "react"; import { Flipper } from "react-flip-toolkit"; import { useTranslation } from "react-i18next"; import { useFetcher, useLoaderData } from "react-router"; @@ -13,12 +13,14 @@ import { import { FormWithConfirm } from "~/components/FormWithConfirm"; import { Placeholder } from "~/components/Placeholder"; import { SubmitButton } from "~/components/SubmitButton"; +import { SendouForm } from "~/form/SendouForm"; import { useAutoRefresh } from "~/hooks/useAutoRefresh"; import { useIsMounted } from "~/hooks/useIsMounted"; import { useWindowSize } from "~/hooks/useWindowSize"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { LFGGroupCard } from "../components/LFGGroupCard"; import type { loader } from "../loaders/to.$id.looking.server"; +import { joinQueueFormSchema } from "../tournament-lfg-schemas"; export { action } from "../actions/to.$id.looking.server"; export { loader } from "../loaders/to.$id.looking.server"; @@ -26,7 +28,7 @@ export { loader } from "../loaders/to.$id.looking.server"; import styles from "./to.$id.looking.module.css"; export const handle: SendouRouteHandle = { - i18n: ["q", "tournament"], + i18n: ["q", "tournament", "forms"], }; export default function TournamentLFGShell() { @@ -82,6 +84,13 @@ function Groups() { ); + const hasOwnSection = data.ownGroup ?? data.ownTeam; + const ownTabMemberCount = data.ownGroup + ? data.ownGroup.members.length + : data.ownTeam + ? data.ownTeam.members.length + : 0; + const ownGroupElement = data.ownGroup ? (
@@ -92,6 +101,14 @@ function Groups() {
) : null; + const leftColumnContent = data.ownGroup ? ( + ownGroupElement + ) : data.ownTeam ? ( + + ) : ( + + ); + return (
- {data.ownGroup ? ( - + {hasOwnSection ? ( + {t("q:looking.columns.myGroup")} ) : null} - {ownGroupElement} + {leftColumnContent}
) : null} @@ -127,8 +144,8 @@ function Groups() { {t("q:looking.columns.invitations")} ) : null} - {isMobile && data.ownGroup ? ( - + {isMobile ? ( + {t("q:looking.columns.myGroup")} ) : null} @@ -136,7 +153,6 @@ function Groups() {
{t("q:looking.columns.available")} - {!data.ownGroup ? : null} {(isMobile ? data.groups.filter( (group) => @@ -161,7 +177,6 @@ function Groups() {
- {!data.ownGroup ? : null} {groupsReceivedLikesFrom.map((group) => ( - {ownGroupElement} + {leftColumnContent}
{!isMobile ? (
{t("q:looking.columns.invitations")} - {!data.ownGroup ? : null} {groupsReceivedLikesFrom.map((group) => ( - - - {t("q:looking.joinQPrompt")} - - + + {({ FormField }) => } + + ); +} + +function TeamQueueSection() { + const { t } = useTranslation(["q"]); + const data = useLoaderData(); + const fetcher = useFetcher(); + + if (!data.ownTeam) return null; + + return ( +
+ + + + {t("q:looking.joinQPrompt")} + + +
); } diff --git a/app/features/tournament-lfg/tournament-lfg-schemas.server.ts b/app/features/tournament-lfg/tournament-lfg-schemas.server.ts index 413806ae2..0a3dae5b0 100644 --- a/app/features/tournament-lfg/tournament-lfg-schemas.server.ts +++ b/app/features/tournament-lfg/tournament-lfg-schemas.server.ts @@ -1,13 +1,10 @@ import { z } from "zod"; import { _action, checkboxValueToBoolean, falsyToNull, id } from "~/utils/zod"; import { TOURNAMENT_LFG } from "./tournament-lfg-constants"; +import { joinQueueFormSchema } from "./tournament-lfg-schemas"; export const lookingSchema = z.union([ - z.object({ - _action: _action("JOIN_QUEUE"), - // xxx: use SendouForm style - stayAsSub: z.preprocess(checkboxValueToBoolean, z.boolean().optional()), - }), + joinQueueFormSchema, z.object({ _action: _action("LIKE"), targetGroupId: id, diff --git a/app/features/tournament-lfg/tournament-lfg-schemas.ts b/app/features/tournament-lfg/tournament-lfg-schemas.ts new file mode 100644 index 000000000..c1a02bbeb --- /dev/null +++ b/app/features/tournament-lfg/tournament-lfg-schemas.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; +import { stringConstant, toggle } from "~/form/fields"; + +export const joinQueueFormSchema = z.object({ + _action: stringConstant("JOIN_QUEUE"), + stayAsSub: toggle({ + label: "labels.stayAsSub", + bottomText: "bottomTexts.stayAsSub", + }), +}); diff --git a/locales/da/forms.json b/locales/da/forms.json index 844a727ab..592b8cf66 100644 --- a/locales/da/forms.json +++ b/locales/da/forms.json @@ -182,5 +182,7 @@ "options.artSource.ALL": "", "options.artSource.MADE-BY": "", "options.artSource.MADE-OF": "", - "labels.tierListUrl": "" + "labels.tierListUrl": "", + "labels.stayAsSub": "", + "bottomTexts.stayAsSub": "" } diff --git a/locales/de/forms.json b/locales/de/forms.json index 8e55ec48b..31989818c 100644 --- a/locales/de/forms.json +++ b/locales/de/forms.json @@ -182,5 +182,7 @@ "options.artSource.ALL": "", "options.artSource.MADE-BY": "", "options.artSource.MADE-OF": "", - "labels.tierListUrl": "" + "labels.tierListUrl": "", + "labels.stayAsSub": "", + "bottomTexts.stayAsSub": "" } diff --git a/locales/en/forms.json b/locales/en/forms.json index e59105b61..1bf3af36c 100644 --- a/locales/en/forms.json +++ b/locales/en/forms.json @@ -182,5 +182,7 @@ "options.artSource.ALL": "All", "options.artSource.MADE-BY": "Made by me", "options.artSource.MADE-OF": "Made of me", - "labels.tierListUrl": "Tier List URL" + "labels.tierListUrl": "Tier List URL", + "labels.stayAsSub": "Stay as sub", + "bottomTexts.stayAsSub": "If you don't find a full team before registration ends, you will stay as a solo sub for the duration of the tournament" } diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json index d2bae4c66..6ce98535f 100644 --- a/locales/es-ES/forms.json +++ b/locales/es-ES/forms.json @@ -182,5 +182,7 @@ "options.artSource.ALL": "", "options.artSource.MADE-BY": "", "options.artSource.MADE-OF": "", - "labels.tierListUrl": "" + "labels.tierListUrl": "", + "labels.stayAsSub": "", + "bottomTexts.stayAsSub": "" } diff --git a/locales/es-US/forms.json b/locales/es-US/forms.json index 5aa23fd75..96ebbcd45 100644 --- a/locales/es-US/forms.json +++ b/locales/es-US/forms.json @@ -182,5 +182,7 @@ "options.artSource.ALL": "", "options.artSource.MADE-BY": "", "options.artSource.MADE-OF": "", - "labels.tierListUrl": "" + "labels.tierListUrl": "", + "labels.stayAsSub": "", + "bottomTexts.stayAsSub": "" } diff --git a/locales/fr-CA/forms.json b/locales/fr-CA/forms.json index c41790a7e..bf6128692 100644 --- a/locales/fr-CA/forms.json +++ b/locales/fr-CA/forms.json @@ -182,5 +182,7 @@ "options.artSource.ALL": "", "options.artSource.MADE-BY": "", "options.artSource.MADE-OF": "", - "labels.tierListUrl": "" + "labels.tierListUrl": "", + "labels.stayAsSub": "", + "bottomTexts.stayAsSub": "" } diff --git a/locales/fr-EU/forms.json b/locales/fr-EU/forms.json index 99aa434a9..c24016450 100644 --- a/locales/fr-EU/forms.json +++ b/locales/fr-EU/forms.json @@ -182,5 +182,7 @@ "options.artSource.ALL": "", "options.artSource.MADE-BY": "", "options.artSource.MADE-OF": "", - "labels.tierListUrl": "" + "labels.tierListUrl": "", + "labels.stayAsSub": "", + "bottomTexts.stayAsSub": "" } diff --git a/locales/he/forms.json b/locales/he/forms.json index eee78d70f..56739ea5e 100644 --- a/locales/he/forms.json +++ b/locales/he/forms.json @@ -182,5 +182,7 @@ "options.artSource.ALL": "", "options.artSource.MADE-BY": "", "options.artSource.MADE-OF": "", - "labels.tierListUrl": "" + "labels.tierListUrl": "", + "labels.stayAsSub": "", + "bottomTexts.stayAsSub": "" } diff --git a/locales/it/forms.json b/locales/it/forms.json index e2e492b43..584639458 100644 --- a/locales/it/forms.json +++ b/locales/it/forms.json @@ -182,5 +182,7 @@ "options.artSource.ALL": "", "options.artSource.MADE-BY": "", "options.artSource.MADE-OF": "", - "labels.tierListUrl": "" + "labels.tierListUrl": "", + "labels.stayAsSub": "", + "bottomTexts.stayAsSub": "" } diff --git a/locales/ja/forms.json b/locales/ja/forms.json index f7f2b8829..ced1151d4 100644 --- a/locales/ja/forms.json +++ b/locales/ja/forms.json @@ -182,5 +182,7 @@ "options.artSource.ALL": "", "options.artSource.MADE-BY": "", "options.artSource.MADE-OF": "", - "labels.tierListUrl": "" + "labels.tierListUrl": "", + "labels.stayAsSub": "", + "bottomTexts.stayAsSub": "" } diff --git a/locales/ko/forms.json b/locales/ko/forms.json index 6ba178dff..404f831bc 100644 --- a/locales/ko/forms.json +++ b/locales/ko/forms.json @@ -182,5 +182,7 @@ "options.artSource.ALL": "", "options.artSource.MADE-BY": "", "options.artSource.MADE-OF": "", - "labels.tierListUrl": "" + "labels.tierListUrl": "", + "labels.stayAsSub": "", + "bottomTexts.stayAsSub": "" } diff --git a/locales/nl/forms.json b/locales/nl/forms.json index e54894569..028ab8836 100644 --- a/locales/nl/forms.json +++ b/locales/nl/forms.json @@ -182,5 +182,7 @@ "options.artSource.ALL": "", "options.artSource.MADE-BY": "", "options.artSource.MADE-OF": "", - "labels.tierListUrl": "" + "labels.tierListUrl": "", + "labels.stayAsSub": "", + "bottomTexts.stayAsSub": "" } diff --git a/locales/pl/forms.json b/locales/pl/forms.json index e34cc00ca..220cf6151 100644 --- a/locales/pl/forms.json +++ b/locales/pl/forms.json @@ -182,5 +182,7 @@ "options.artSource.ALL": "", "options.artSource.MADE-BY": "", "options.artSource.MADE-OF": "", - "labels.tierListUrl": "" + "labels.tierListUrl": "", + "labels.stayAsSub": "", + "bottomTexts.stayAsSub": "" } diff --git a/locales/pt-BR/forms.json b/locales/pt-BR/forms.json index e2e14c0a1..21fc10f6e 100644 --- a/locales/pt-BR/forms.json +++ b/locales/pt-BR/forms.json @@ -182,5 +182,7 @@ "options.artSource.ALL": "", "options.artSource.MADE-BY": "", "options.artSource.MADE-OF": "", - "labels.tierListUrl": "" + "labels.tierListUrl": "", + "labels.stayAsSub": "", + "bottomTexts.stayAsSub": "" } diff --git a/locales/ru/forms.json b/locales/ru/forms.json index ff76dbdb1..1db7106ae 100644 --- a/locales/ru/forms.json +++ b/locales/ru/forms.json @@ -182,5 +182,7 @@ "options.artSource.ALL": "", "options.artSource.MADE-BY": "", "options.artSource.MADE-OF": "", - "labels.tierListUrl": "" + "labels.tierListUrl": "", + "labels.stayAsSub": "", + "bottomTexts.stayAsSub": "" } diff --git a/locales/zh/forms.json b/locales/zh/forms.json index d365f2cd0..39cfd42d2 100644 --- a/locales/zh/forms.json +++ b/locales/zh/forms.json @@ -182,5 +182,7 @@ "options.artSource.ALL": "", "options.artSource.MADE-BY": "", "options.artSource.MADE-OF": "", - "labels.tierListUrl": "" + "labels.tierListUrl": "", + "labels.stayAsSub": "", + "bottomTexts.stayAsSub": "" }