diff --git a/app/components/match-page/MatchBanner.module.css b/app/components/match-page/MatchBanner.module.css index 536341ab9..2373d8c2c 100644 --- a/app/components/match-page/MatchBanner.module.css +++ b/app/components/match-page/MatchBanner.module.css @@ -48,8 +48,7 @@ .notice { position: absolute; bottom: var(--s-2); - left: 50%; - transform: translateX(-50%); + right: var(--s-2); display: flex; gap: var(--s-0-5); align-items: center; @@ -62,6 +61,23 @@ height: auto; } +.joinBadge { + position: absolute; + bottom: var(--s-2); + left: var(--s-2); + display: flex; + gap: var(--s-1); + align-items: center; + color: var(--color-text-high); + background-color: var(--color-bg-high); + padding: var(--s-0-5) var(--s-1-5); + border-radius: var(--radius-field); + font-size: var(--font-2xs); + font-weight: var(--weight-semi); + height: 32px; + width: 32px; +} + .infoBadge { display: flex; gap: var(--s-1-5); diff --git a/app/components/match-page/MatchBanner.tsx b/app/components/match-page/MatchBanner.tsx index 06eb5cf64..5fae48a75 100644 --- a/app/components/match-page/MatchBanner.tsx +++ b/app/components/match-page/MatchBanner.tsx @@ -1,6 +1,7 @@ import clsx from "clsx"; -import { Check, X } from "lucide-react"; +import { Check, QrCode, X } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { useSearchParams } from "react-router"; import { SendouButton } from "~/components/elements/Button"; import { SendouPopover } from "~/components/elements/Popover"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; @@ -20,6 +21,8 @@ interface MatchBannerProps { stageId: StageId; mode: ModeShort; screenLegal?: boolean; + joinPool?: string | null; + joinViaQr?: boolean; children: React.ReactNode; } @@ -27,6 +30,8 @@ export function MatchBanner({ stageId, mode, screenLegal, + joinPool, + joinViaQr, children, }: MatchBannerProps) { const { t } = useTranslation(["game-misc"]); @@ -45,6 +50,7 @@ export function MatchBanner({
{children}
+ {joinPool ? : null} {screenLegal !== undefined ? ( ) : null} @@ -75,6 +81,8 @@ interface IconBannerProps { header: string; subtitle?: string; screenLegal?: boolean; + joinPool?: string | null; + joinViaQr?: boolean; topRight?: React.ReactNode; testId?: string; } @@ -84,6 +92,8 @@ export function IconBanner({ header, subtitle, screenLegal, + joinPool, + joinViaQr, topRight, testId, }: IconBannerProps) { @@ -94,6 +104,7 @@ export function IconBanner({ {subtitle ? (
{subtitle}
) : null} + {joinPool ? : null} {screenLegal !== undefined ? ( ) : null} @@ -104,6 +115,31 @@ export function IconBanner({ ); } +function JoinPoolBadge({ pool, viaQr }: { pool: string; viaQr?: boolean }) { + const { t } = useTranslation(["q"]); + const [, setSearchParams] = useSearchParams(); + + return ( + + setSearchParams( + { tab: "join" }, + { + preventScrollReset: true, + defaultShouldRevalidate: false, + }, + ) + } + aria-label={t("q:match.pool")} + testId="join-pool-badge" + > + {viaQr ? : pool} + + ); +} + function ScreenNotice({ screenLegal }: { screenLegal: boolean }) { const { t } = useTranslation(["weapons", "q"]); diff --git a/app/features/scrims/components/ScrimMatchBanner.tsx b/app/features/scrims/components/ScrimMatchBanner.tsx index c1552f35e..dd1558f54 100644 --- a/app/features/scrims/components/ScrimMatchBanner.tsx +++ b/app/features/scrims/components/ScrimMatchBanner.tsx @@ -1,3 +1,4 @@ +import { sub } from "date-fns"; import { Ban, Swords } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Link, useLoaderData } from "react-router"; @@ -6,16 +7,22 @@ import { IconBanner, MatchBannerContainer, } from "~/components/match-page/MatchBanner"; +import { useUser } from "~/features/auth/core/user"; +import { resolveActiveRoomLink } from "~/features/chat/room-link-utils"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; import { logger } from "~/utils/logger"; import type { SerializeFrom } from "~/utils/remix"; import { mapsPageWithMapPool, navIconUrl } from "~/utils/urls"; +import * as Scrim from "../core/Scrim"; import type { loader } from "../loaders/scrims.$id.server"; +import { SCRIM } from "../scrims-constants"; import type { ScrimPost } from "../scrims-types"; export function ScrimMatchBanner() { const { t } = useTranslation(["scrims"]); const data = useLoaderData(); + const user = useUser(); const screenLegal = !data.anyUserPrefersNoScreen; @@ -37,6 +44,17 @@ export function ScrimMatchBanner() { const hasMaps = data.post.maps || data.tournamentMapPool; + const acceptedRequest = data.post.requests[0]; + const activeRoomLink = resolveActiveRoomLink({ + roomLinks: data.roomLinks, + freshnessCutoff: dateToDatabaseTimestamp( + sub(new Date(), { minutes: SCRIM.ROOM_LINK_FRESHNESS_MINUTES }), + ), + viewerUserId: user?.id, + members: [...data.post.users, ...acceptedRequest.users], + }); + const joinViaQr = Boolean(activeRoomLink.joinLink) && !activeRoomLink.isStale; + return ( + @@ -92,6 +107,8 @@ export function SendouQMatchBanner({ data }: { data: SendouQMatchLoaderData }) { subtitle={t("q:match.cancelRequested.subtitle", { teamName: cancelRequesterName, })} + joinPool={joinPool} + joinViaQr={joinViaQr} /> ) : ( diff --git a/app/features/sendouq-match/components/SendouQMatchTabs.tsx b/app/features/sendouq-match/components/SendouQMatchTabs.tsx index 280e0d9dd..ec9d729cb 100644 --- a/app/features/sendouq-match/components/SendouQMatchTabs.tsx +++ b/app/features/sendouq-match/components/SendouQMatchTabs.tsx @@ -75,15 +75,10 @@ export function SendouQMatchTabs({ data }: { data: SendouQMatchLoaderData }) { (m) => m.winnerGroupId !== null, ); - const tabs: Array<"join" | "rosters" | "action" | "result"> = []; - if (isLocked) { - tabs.push("result", "rosters"); - } else { - if (isParticipant) tabs.push("join"); - tabs.push("rosters"); - } + const tabs: Array<"join" | "rosters" | "action" | "result"> = ["rosters"]; + if (!isLocked && isParticipant) tabs.push("join"); if (showActionTab) tabs.push("action"); - if (!isLocked && hasReportedMaps) tabs.push("result"); + if (isLocked || hasReportedMaps) tabs.push("result"); const allMembers = [ ...data.match.groupAlpha.members, diff --git a/app/features/tournament-match/components/TournamentMatchBanner.tsx b/app/features/tournament-match/components/TournamentMatchBanner.tsx index e8bc4d8e6..b1760e01b 100644 --- a/app/features/tournament-match/components/TournamentMatchBanner.tsx +++ b/app/features/tournament-match/components/TournamentMatchBanner.tsx @@ -36,7 +36,15 @@ export function TournamentMatchBanner({ const { t } = useTranslation(["tournament"]); const { formatDate } = useTimeFormat(); const tournament = useTournament(); - const { currentMap, teamsMissingActiveRoster, matchIsLocked } = useMatch(); + const { + currentMap, + teamsMissingActiveRoster, + matchIsLocked, + joinPool, + activeRoomLink, + } = useMatch(); + const joinViaQr = + Boolean(activeRoomLink?.joinLink) && !activeRoomLink?.isStale; const opponentOne = data.match.opponentOne; const opponentTwo = data.match.opponentTwo; @@ -107,6 +115,8 @@ export function TournamentMatchBanner({ teams: teamsMissingActiveRoster.join(" & "), })} screenLegal={screenLegal} + joinPool={joinPool} + joinViaQr={joinViaQr} testId="active-roster-needed-text" /> ) : data.matchIsOver ? ( @@ -119,12 +129,16 @@ export function TournamentMatchBanner({ header={pickBanBanner.header} subtitle={pickBanBanner.subtitle} screenLegal={screenLegal} + joinPool={joinPool} + joinViaQr={joinViaQr} /> ) : currentMap ? ( b.type === "round_robin", - ); - const bracketIdx = tournament.brackets.findIndex((b) => - b.data.match.some((m) => m.id === data.match.id), - ); - const bracket = tournament.brackets[bracketIdx]; - const bracketMatch = bracket?.data.match.find((m) => m.id === data.match.id); - const group = bracket?.data.group.find( - (g) => g.id === bracketMatch?.group_id, - ); - - const poolCode = tournament.resolvePoolCode({ - hostingTeamId: hostingTeam.id, - groupLetters: - group && bracket?.type === "round_robin" - ? groupNumberToLetters(group.number) - : undefined, - bracketNumber: - hasRoundRobin && bracket?.type !== "round_robin" - ? bracketIdx + 1 - : undefined, - }); - - const activeRoomLink = resolveActiveRoomLink({ - roomLinks: data.roomLinks, - freshnessCutoff: data.match.startedAt ?? 0, - viewerUserId: user?.id, - members: data.match.players, - }); - return ( diff --git a/app/features/tournament-match/match-page-context.tsx b/app/features/tournament-match/match-page-context.tsx index 64241afa5..30aacd4dd 100644 --- a/app/features/tournament-match/match-page-context.tsx +++ b/app/features/tournament-match/match-page-context.tsx @@ -1,13 +1,19 @@ import * as React from "react"; import { TAB_KEYS } from "~/components/match-page/MatchTabs"; import { useUser } from "~/features/auth/core/user"; +import { resolveActiveRoomLink } from "~/features/chat/room-link-utils"; import { useTournament } from "~/features/tournament/routes/to.$id"; import { isLeagueRoundLocked } from "~/features/tournament/tournament-utils"; import * as PickBan from "~/features/tournament-bracket/core/PickBan"; import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; -import { tournamentTeamToActiveRosterUserIds } from "~/features/tournament-bracket/tournament-bracket-utils"; +import { + groupNumberToLetters, + tournamentTeamToActiveRosterUserIds, +} from "~/features/tournament-bracket/tournament-bracket-utils"; import type { TournamentMatchLoaderData } from "./loaders/to.$id.matches.$mid.server"; -import { matchIsLocked } from "./tournament-match-utils"; +import { matchIsLocked, resolveHostingTeam } from "./tournament-match-utils"; + +type ActiveRoomLink = ReturnType; export type MatchPageTeam = NonNullable>; @@ -28,6 +34,8 @@ type MatchPageContextValue = { turnOfResult: ReturnType; isPickBanStep: boolean; matchIsLocked: boolean; + joinPool: string | null; + activeRoomLink: ActiveRoomLink | null; }; const MatchPageContext = React.createContext( @@ -93,6 +101,17 @@ export function MatchPageProvider({ scores, }); + const joinPool = resolveJoinPool({ tournament, data, teams }); + + const activeRoomLink = data.canJoin + ? resolveActiveRoomLink({ + roomLinks: data.roomLinks, + freshnessCutoff: data.match.startedAt ?? 0, + viewerUserId: user?.id, + members: data.match.players, + }) + : null; + const tabs = resolveVisibleTabs({ matchIsOver: data.matchIsOver, canReportScore: tournament.canReportScore({ @@ -126,6 +145,8 @@ export function MatchPageProvider({ turnOfResult, isPickBanStep, matchIsLocked: lockedForCast, + joinPool, + activeRoomLink, }} > {children} @@ -168,15 +189,11 @@ function resolveVisibleTabs({ leagueRoundLocked: boolean; lockedForCast: boolean; }): MatchTabKey[] { - const tabs: MatchTabKey[] = []; + const tabs: MatchTabKey[] = [TAB_KEYS.ROSTERS]; - if (matchIsOver) { - tabs.push(TAB_KEYS.RESULT); - } if (canJoin) { tabs.push(TAB_KEYS.JOIN); } - tabs.push(TAB_KEYS.ROSTERS); if ( !leagueRoundLocked && (isPickBanStep || @@ -191,13 +208,56 @@ function resolveVisibleTabs({ if (isAdminEligible) { tabs.push(TAB_KEYS.ADMIN); } - if (!matchIsOver && (hasReportedMaps || hasPickBanEvents)) { + if (matchIsOver || hasReportedMaps || hasPickBanEvents) { tabs.push(TAB_KEYS.RESULT); } return tabs; } +function resolveJoinPool({ + tournament, + data, + teams, +}: { + tournament: ReturnType; + data: TournamentMatchLoaderData; + teams: [MatchPageTeam | null, MatchPageTeam | null]; +}): string | null { + if (!data.canJoin) return null; + + const [teamOne, teamTwo] = teams; + if (!teamOne || !teamTwo) return null; + + const hostingTeam = resolveHostingTeam([teamOne, teamTwo]); + + const hasRoundRobin = tournament.brackets.some( + (b) => b.type === "round_robin", + ); + const bracketIdx = tournament.brackets.findIndex((b) => + b.data.match.some((m) => m.id === data.match.id), + ); + const bracket = tournament.brackets[bracketIdx]; + const bracketMatch = bracket?.data.match.find((m) => m.id === data.match.id); + const group = bracket?.data.group.find( + (g) => g.id === bracketMatch?.group_id, + ); + + const poolCode = tournament.resolvePoolCode({ + hostingTeamId: hostingTeam.id, + groupLetters: + group && bracket?.type === "round_robin" + ? groupNumberToLetters(group.number) + : undefined, + bracketNumber: + hasRoundRobin && bracket?.type !== "round_robin" + ? bracketIdx + 1 + : undefined, + }); + + return `${poolCode.prefix}${poolCode.suffix}`; +} + function resolveTeamsMissingActiveRoster( teams: [MatchPageTeam | null, MatchPageTeam | null], minMembersPerTeam: number,