Default to rosters tab on match page + show pool/qr code indicator

Closes #3053
This commit is contained in:
Kalle
2026-05-12 20:27:04 +03:00
parent 8c24239e8c
commit fe782602fe
10 changed files with 190 additions and 67 deletions

View File

@@ -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);

View File

@@ -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({
</div>
<div className={clsx(styles.info, styles.thickText)}>{children}</div>
{joinPool ? <JoinPoolBadge pool={joinPool} viaQr={joinViaQr} /> : null}
{screenLegal !== undefined ? (
<ScreenNotice screenLegal={screenLegal} />
) : 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 ? (
<div className={styles.iconBannerSubtitle}>{subtitle}</div>
) : null}
{joinPool ? <JoinPoolBadge pool={joinPool} viaQr={joinViaQr} /> : null}
{screenLegal !== undefined ? (
<ScreenNotice screenLegal={screenLegal} />
) : null}
@@ -104,6 +115,31 @@ export function IconBanner({
);
}
function JoinPoolBadge({ pool, viaQr }: { pool: string; viaQr?: boolean }) {
const { t } = useTranslation(["q"]);
const [, setSearchParams] = useSearchParams();
return (
<SendouButton
variant="minimal"
className={styles.joinBadge}
onPress={() =>
setSearchParams(
{ tab: "join" },
{
preventScrollReset: true,
defaultShouldRevalidate: false,
},
)
}
aria-label={t("q:match.pool")}
testId="join-pool-badge"
>
{viaQr ? <QrCode size={18} /> : pool}
</SendouButton>
);
}
function ScreenNotice({ screenLegal }: { screenLegal: boolean }) {
const { t } = useTranslation(["weapons", "q"]);

View File

@@ -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<typeof loader>();
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 (
<MatchBannerContainer>
<IconBanner
@@ -44,6 +62,8 @@ export function ScrimMatchBanner() {
header={t("scrims:banner.freeForm.header")}
subtitle={t("scrims:banner.freeForm.subtitle")}
screenLegal={screenLegal}
joinPool={Scrim.resolvePoolCode(data.post.id)}
joinViaQr={joinViaQr}
topRight={
hasMaps ? (
<MapsLink

View File

@@ -14,10 +14,9 @@ import { dateToDatabaseTimestamp } from "~/utils/dates";
import { teamPage } 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";
const SCRIM_ROOM_LINK_FRESHNESS_MINUTES = 30;
export function ScrimMatchTabs() {
const { t } = useTranslation(["q"]);
const user = useUser();
@@ -30,14 +29,14 @@ export function ScrimMatchTabs() {
const activeRoomLink = resolveActiveRoomLink({
roomLinks: data.roomLinks,
freshnessCutoff: dateToDatabaseTimestamp(
sub(new Date(), { minutes: SCRIM_ROOM_LINK_FRESHNESS_MINUTES }),
sub(new Date(), { minutes: SCRIM.ROOM_LINK_FRESHNESS_MINUTES }),
),
viewerUserId: user?.id,
members: allMembers,
});
return (
<MatchTabs tabs={[TAB_KEYS.JOIN, TAB_KEYS.ROSTERS]}>
<MatchTabs tabs={[TAB_KEYS.ROSTERS, TAB_KEYS.JOIN]}>
<MatchJoinTab
{...activeRoomLink}
onConfirmRoom={onConfirmRoom}

View File

@@ -19,4 +19,5 @@ export const SCRIM = {
CANCEL_REASON_MAX_LENGTH: 500,
REQUEST_MESSAGE_MAX_LENGTH: 200,
MAX_TIME_RANGE_MS: 3 * 60 * 60 * 1000, // 3 hours
ROOM_LINK_FRESHNESS_MINUTES: 30,
};

View File

@@ -13,6 +13,8 @@ import {
import bannerStyles from "~/components/match-page/MatchBanner.module.css";
import { MatchBannerBottomRow } from "~/components/match-page/MatchBannerBottomRow";
import { MatchBannerTopRow } from "~/components/match-page/MatchBannerTopRow";
import { useUser } from "~/features/auth/core/user";
import { resolveActiveRoomLink } from "~/features/chat/room-link-utils";
import { SENDOUQ_BEST_OF } from "~/features/sendouq/q-constants";
import { useAutoRerender } from "~/hooks/useAutoRerender";
import { databaseTimestampToDate } from "~/utils/dates";
@@ -23,6 +25,7 @@ import type { SendouQMatchLoaderData } from "../loaders/q.match.$id.server";
export function SendouQMatchBanner({ data }: { data: SendouQMatchLoaderData }) {
const { t } = useTranslation(["q"]);
const user = useUser();
const cancelRequested = Boolean(data.match.cancelRequestedByUserId);
const cancelRequesterSide = SendouQMatch.resolveGroupMemberOf({
@@ -82,6 +85,18 @@ export function SendouQMatchBanner({ data }: { data: SendouQMatchLoaderData }) {
const currentMap = data.match.currentMap;
invariant(currentMap);
const joinPool = `SQ${String(data.match.id).at(-1)}`;
const activeRoomLink = resolveActiveRoomLink({
roomLinks: data.roomLinks,
freshnessCutoff: data.match.createdAt,
viewerUserId: user?.id,
members: [
...data.match.groupAlpha.members,
...data.match.groupBravo.members,
],
});
const joinViaQr = Boolean(activeRoomLink.joinLink) && !activeRoomLink.isStale;
return (
<MatchBannerContainer>
<SendouQMatchBannerTopRow data={data} awaitingConfirmation={false} />
@@ -92,6 +107,8 @@ export function SendouQMatchBanner({ data }: { data: SendouQMatchLoaderData }) {
subtitle={t("q:match.cancelRequested.subtitle", {
teamName: cancelRequesterName,
})}
joinPool={joinPool}
joinViaQr={joinViaQr}
/>
) : (
<MatchBanner
@@ -100,6 +117,8 @@ export function SendouQMatchBanner({ data }: { data: SendouQMatchLoaderData }) {
screenLegal={
!data.match.groupAlpha.noScreen && !data.match.groupBravo.noScreen
}
joinPool={joinPool}
joinViaQr={joinViaQr}
>
<CurrentMapVotesBadge voters={currentMap.voters} />
</MatchBanner>

View File

@@ -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,

View File

@@ -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 ? (
<MatchBanner
stageId={currentMap.stageId}
mode={currentMap.mode}
screenLegal={screenLegal}
joinPool={joinPool}
joinViaQr={joinViaQr}
>
<CurrentMapPickInfo
source={currentMap.source}

View File

@@ -10,16 +10,10 @@ import type {
} from "~/components/match-page/MatchTimeline";
import { resolveRoomPass } from "~/components/match-page/utils";
import { useUser } from "~/features/auth/core/user";
import {
resolveActiveRoomLink,
useConfirmRoom,
} from "~/features/chat/room-link-utils";
import { useConfirmRoom } from "~/features/chat/room-link-utils";
import { useTournament } from "~/features/tournament/routes/to.$id";
import * as PickBan from "~/features/tournament-bracket/core/PickBan";
import {
groupNumberToLetters,
tournamentTeamToActiveRosterUserIds,
} from "~/features/tournament-bracket/tournament-bracket-utils";
import { tournamentTeamToActiveRosterUserIds } from "~/features/tournament-bracket/tournament-bracket-utils";
import { databaseTimestampToJavascriptTimestamp } from "~/utils/dates";
import { tournamentTeamPage } from "~/utils/urls";
import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server";
@@ -318,54 +312,23 @@ function slotOfEvent({
}
function TournamentMatchJoinTab({ data }: { data: TournamentMatchLoaderData }) {
const tournament = useTournament();
const user = useUser();
const { onConfirmRoom, isConfirming } = useConfirmRoom();
const {
teams: [teamOne, teamTwo],
joinPool,
activeRoomLink,
} = useMatch();
if (!teamOne || !teamTwo) return null;
if (!teamOne || !teamTwo || !joinPool || !activeRoomLink) 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,
});
const activeRoomLink = resolveActiveRoomLink({
roomLinks: data.roomLinks,
freshnessCutoff: data.match.startedAt ?? 0,
viewerUserId: user?.id,
members: data.match.players,
});
return (
<MatchJoinTab
{...activeRoomLink}
hostedBy={activeRoomLink.hostedBy ?? hostingTeam.name}
onConfirmRoom={onConfirmRoom}
isConfirming={isConfirming}
pool={`${poolCode.prefix}${poolCode.suffix}`}
pool={joinPool}
pass={resolveRoomPass(hostingTeam.id)}
showNoSplatnetAlert={data.anyUserPrefersNoSplatnet}
/>

View File

@@ -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<typeof resolveActiveRoomLink>;
export type MatchPageTeam = NonNullable<ReturnType<Tournament["teamById"]>>;
@@ -28,6 +34,8 @@ type MatchPageContextValue = {
turnOfResult: ReturnType<typeof PickBan.turnOf>;
isPickBanStep: boolean;
matchIsLocked: boolean;
joinPool: string | null;
activeRoomLink: ActiveRoomLink | null;
};
const MatchPageContext = React.createContext<MatchPageContextValue | null>(
@@ -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<typeof useTournament>;
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,