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,