-
- {score.alpha}-{score.bravo}
-
+ {score ? (
+
+ {score.alpha}-{score.bravo}
+
+ ) : null}
{isOngoing ? (
{t("q:match.timeline.live")}
diff --git a/app/components/match-page/SecondaryAction.module.css b/app/components/match-page/SecondaryAction.module.css
new file mode 100644
index 000000000..47f10509e
--- /dev/null
+++ b/app/components/match-page/SecondaryAction.module.css
@@ -0,0 +1,38 @@
+.rootCollapsed {
+ display: flex;
+ justify-content: center;
+ background-color: var(--color-bg-higher);
+ border-radius: 0 0 var(--radius-box) var(--radius-box);
+ padding: var(--s-2);
+ margin: var(--s-4) calc(-1 * var(--s-4)) calc(-1 * var(--s-6));
+}
+
+.root {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--s-4);
+ background-color: var(--color-bg-higher);
+ border-radius: 0 0 var(--radius-box) var(--radius-box);
+ padding: var(--s-4);
+ margin: var(--s-4) calc(-1 * var(--s-4)) calc(-1 * var(--s-6));
+ container-type: inline-size;
+ position: relative;
+
+ &.standalone {
+ margin-block-start: calc(-1 * var(--s-6));
+ min-height: 200px;
+ justify-content: center;
+ }
+}
+
+.collapseButton {
+ position: absolute;
+ inset-block-start: var(--s-2);
+ inset-inline-end: var(--s-3);
+
+ & svg {
+ min-width: 22px;
+ max-width: 22px;
+ }
+}
diff --git a/app/components/match-page/SecondaryAction.tsx b/app/components/match-page/SecondaryAction.tsx
new file mode 100644
index 000000000..01f98c796
--- /dev/null
+++ b/app/components/match-page/SecondaryAction.tsx
@@ -0,0 +1,62 @@
+import clsx from "clsx";
+import { ChevronUp } from "lucide-react";
+import type * as React from "react";
+import { SendouButton } from "../elements/Button";
+import styles from "./SecondaryAction.module.css";
+
+interface SecondaryActionProps {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ collapsedLabel: string;
+ collapsedIcon?: JSX.Element;
+ expandedAriaLabel?: string;
+ standalone?: boolean;
+ children: React.ReactNode;
+}
+
+/**
+ * Generic collapsible panel rendered below the primary match action.
+ * Hosts optional follow-up actions (e.g. weapon reporting, scrim map list
+ * management) and switches to a full-tab standalone variant when there is
+ * no primary action to sit underneath.
+ */
+export function SecondaryAction({
+ isOpen,
+ onOpenChange,
+ collapsedLabel,
+ collapsedIcon,
+ expandedAriaLabel,
+ standalone,
+ children,
+}: SecondaryActionProps) {
+ if (!isOpen && !standalone) {
+ return (
+
+ onOpenChange(true)}
+ >
+ {collapsedLabel}
+
+
+ );
+ }
+
+ return (
+
+ {standalone ? null : (
+ }
+ onPress={() => onOpenChange(false)}
+ className={styles.collapseButton}
+ aria-label={expandedAriaLabel ?? collapsedLabel}
+ />
+ )}
+ {children}
+
+ );
+}
diff --git a/app/components/match-page/WeaponReporter.module.css b/app/components/match-page/WeaponReporter.module.css
index ea167ec24..4b056b1e9 100644
--- a/app/components/match-page/WeaponReporter.module.css
+++ b/app/components/match-page/WeaponReporter.module.css
@@ -1,15 +1,3 @@
-.root {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: var(--s-4);
- background-color: var(--color-bg-higher);
- border-radius: 0 0 var(--radius-box) var(--radius-box);
- padding: var(--s-4);
- margin: var(--s-4) calc(-1 * var(--s-4)) calc(-1 * var(--s-6));
- container-type: inline-size;
-}
-
.pastRow {
display: flex;
align-items: center;
@@ -76,33 +64,3 @@
display: flex;
gap: var(--s-1);
}
-
-.rootCollapsed {
- display: flex;
- justify-content: center;
- background-color: var(--color-bg-higher);
- border-radius: 0 0 var(--radius-box) var(--radius-box);
- padding: var(--s-2);
- margin: var(--s-4) calc(-1 * var(--s-4)) calc(-1 * var(--s-6));
-}
-
-.rootExpanded {
- position: relative;
-}
-
-.rootStandalone {
- margin-block-start: calc(-1 * var(--s-6));
- min-height: 200px;
- justify-content: center;
-}
-
-.collapseButton {
- position: absolute;
- top: var(--s-2);
- right: var(--s-3);
-
- & svg {
- min-width: 22px;
- max-width: 22px;
- }
-}
diff --git a/app/components/match-page/WeaponReporter.tsx b/app/components/match-page/WeaponReporter.tsx
index 8508caf1f..49c7ef6bd 100644
--- a/app/components/match-page/WeaponReporter.tsx
+++ b/app/components/match-page/WeaponReporter.tsx
@@ -1,5 +1,4 @@
-import clsx from "clsx";
-import { ChevronUp, Crosshair } from "lucide-react";
+import { Crosshair } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useFetcher } from "react-router";
@@ -13,6 +12,7 @@ import { abilityImageUrl, SETTINGS_PAGE } from "~/utils/urls";
import { SendouButton } from "../elements/Button";
import { Image, StageImage, WeaponImage } from "../Image";
import { WeaponSelect } from "../WeaponSelect";
+import { SecondaryAction } from "./SecondaryAction";
import styles from "./WeaponReporter.module.css";
interface WeaponReporterMap {
@@ -64,37 +64,14 @@ export function WeaponReporter({
);
};
- if (!isOpen && !standalone) {
- return (
-
- }
- onPress={() => handleToggle(true)}
- >
- {t("q:match.actions.reportWeapons")}
-
-
- );
- }
-
return (
- }
+ standalone={standalone}
>
- {standalone ? null : (
- }
- onPress={() => handleToggle(false)}
- className={styles.collapseButton}
- aria-label={t("q:match.actions.reportWeapons")}
- />
- )}
{inputTargetMap ? (
@@ -153,7 +130,7 @@ export function WeaponReporter({
))}
) : null}
-
+
);
}
diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts
index e1bb063ee..aa797acfd 100644
--- a/app/db/seed/index.ts
+++ b/app/db/seed/index.ts
@@ -3188,7 +3188,34 @@ async function scrimPosts() {
return result;
};
- for (let i = 0; i < 20; i++) {
+ // Deterministic post 1: admin (Sendou) vs N-ZAP. The e2e map-by-map test
+ // navigates straight to /scrims/1 and relies on this being an accepted
+ // scrim with admin on the ALPHA side and N-ZAP on the BRAVO side.
+ const adminVsNzapAt = date(true);
+ const adminVsNzapPostId = await ScrimPostRepository.insert({
+ at: adminVsNzapAt,
+ rangeEnd: null,
+ isScheduledForFuture: true,
+ teamId: null,
+ text: null,
+ visibility: null,
+ users: users()
+ .map((u) => ({ ...u, isOwner: 0 }))
+ .concat({ userId: ADMIN_ID, isOwner: 1 }),
+ managedByAnyone: true,
+ maps: null,
+ mapsTournamentId: 4,
+ });
+ await ScrimPostRepository.insertRequest({
+ scrimPostId: adminVsNzapPostId,
+ users: users()
+ .map((u) => ({ ...u, isOwner: 0 }))
+ .concat({ userId: NZAP_TEST_ID, isOwner: 1 }),
+ message: null,
+ });
+ await ScrimPostRepository.acceptRequest(1);
+
+ for (let i = 0; i < 19; i++) {
const divs = divRange();
const atTime = date();
const hasRangeEnd = Math.random() > 0.5;
@@ -3258,7 +3285,9 @@ async function scrimPostRequests() {
.where("TeamMember.teamId", "=", 1)
.execute();
- for (const id of [1, 5, 12, 14, 19]) {
+ // Post 1 is already accepted (admin-vs-nzap, seeded in scrimPosts()), so it
+ // is excluded here.
+ for (const id of [5, 12, 14, 19]) {
await ScrimPostRepository.insertRequest({
scrimPostId: id,
users: allianceRogueMembers.map((member) => ({
@@ -3272,8 +3301,6 @@ async function scrimPostRequests() {
: null,
});
}
-
- await ScrimPostRepository.acceptRequest(3);
}
async function associations() {
diff --git a/app/db/tables.ts b/app/db/tables.ts
index 142a0970b..23d734911 100644
--- a/app/db/tables.ts
+++ b/app/db/tables.ts
@@ -1259,6 +1259,27 @@ export interface ScrimPost {
updatedAt: Generated