sendou.ink/app/components/match-page/MatchActionTab.tsx
2026-05-25 17:35:44 +03:00

335 lines
8.3 KiB
TypeScript

import clsx from "clsx";
import { Check } from "lucide-react";
import type * as React from "react";
import { useState } from "react";
import { Radio, RadioGroup } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { useWebHaptics } from "web-haptics/react";
import { shortStageName } from "~/modules/in-game-lists/stage-ids";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import type { CommonUser } from "~/utils/kysely.server";
import { Avatar } from "../Avatar";
import { SendouButton } from "../elements/Button";
import { SendouTabPanel } from "../elements/Tabs";
import { ModeImage, StageImage } from "../Image";
import styles from "./MatchActionTab.module.css";
import { TAB_KEYS } from "./MatchTabs";
import {
MatchTimeline,
type MatchTimelineProps,
type TimelineMap,
} from "./MatchTimeline";
const LONG_TEAM_NAME_THRESHOLD = 16;
interface ActionTabTeam {
id: number;
name: string;
avatar?: string;
}
interface SetEndingData extends MatchTimelineProps {
score: { alpha: number; bravo: number };
currentRosters: { alpha: CommonUser[]; bravo: CommonUser[] };
setEndingTeamIds: number[];
}
interface MatchActionTabProps {
teams: [ActionTabTeam, ActionTabTeam];
ownTeamId: number | null;
stageId: StageId;
mode: ModeShort;
withPoints: boolean;
onSubmit?: (data: { winnerId: number; points?: [number, number] }) => void;
isSubmitting?: boolean;
setEnding?: SetEndingData;
actionButtons?: React.ReactNode;
secondaryAction?: React.ReactNode;
}
export function MatchActionTab({
teams,
ownTeamId,
stageId,
mode,
withPoints,
onSubmit,
isSubmitting,
setEnding,
actionButtons,
secondaryAction,
}: MatchActionTabProps) {
const { t } = useTranslation(["q", "game-misc", "common"]);
const [winnerId, setWinnerId] = useState<number | null>(null);
const [isKo, setIsKo] = useState(false);
const [confirming, setConfirming] = useState(false);
const { trigger } = useWebHaptics();
const canSubmit = winnerId !== null;
const isOnTeam =
ownTeamId != null &&
(teams[0].id === ownTeamId || teams[1].id === ownTeamId);
const submit = () => {
if (winnerId === null) return;
const submitPoints: [number, number] | undefined = withPoints
? isKo
? winnerId === teams[0].id
? [100, 0]
: [0, 100]
: [0, 0]
: undefined;
onSubmit?.({ winnerId, points: submitPoints });
};
return (
<SendouTabPanel id={TAB_KEYS.ACTION}>
{confirming && winnerId !== null && setEnding ? (
<SetEndingConfirmation
setEnding={setEnding}
stageId={stageId}
mode={mode}
winnerId={winnerId}
teams={teams}
withPoints={withPoints}
isKo={isKo}
isSubmitting={isSubmitting}
onBack={() => setConfirming(false)}
onConfirm={submit}
/>
) : (
<div className={clsx(styles.root, { [styles.withPoints]: withPoints })}>
<div className={styles.title}>{t("q:match.action.selectWinner")}</div>
{actionButtons ? (
<div className={styles.actionButtons}>{actionButtons}</div>
) : null}
<RadioGroup
value={winnerId !== null ? String(winnerId) : null}
onChange={(value) => {
const selectedId = Number(value);
setWinnerId(selectedId);
const isEnemySelection = isOnTeam && selectedId !== ownTeamId;
if (isEnemySelection) {
trigger([
{ duration: 40, intensity: 0.7 },
{ delay: 40, duration: 40, intensity: 0.7 },
{ delay: 40, duration: 40, intensity: 0.9 },
{ delay: 40, duration: 50, intensity: 0.6 },
]);
} else {
trigger([
{ duration: 30 },
{ delay: 60, duration: 40, intensity: 1 },
]);
}
}}
isDisabled={isSubmitting}
aria-label={t("q:match.action.selectWinner")}
className={styles.selectionRow}
>
<TeamRadioOption
team={teams[0]}
isOwnTeam={teams[0].id === ownTeamId}
hideLabel={ownTeamId === null}
className={styles.alpha}
testId="winner-radio-1"
/>
<StageImage
stageId={stageId}
width={90}
className={styles.stageImage}
containerClassName={styles.stageImageWrapper}
/>
<div className={styles.stageLabel}>
<ModeImage mode={mode} size={14} />
<span>{shortStageName(t(`game-misc:STAGE_${stageId}`))}</span>
</div>
<TeamRadioOption
team={teams[1]}
isOwnTeam={teams[1].id === ownTeamId}
hideLabel={ownTeamId == null}
className={clsx(styles.bravo)}
testId="winner-radio-2"
/>
</RadioGroup>
{withPoints ? (
<div className={styles.ko}>
<label className={styles.koLabel}>
<input
type="checkbox"
checked={isKo}
onChange={(e) => setIsKo(e.target.checked)}
data-testid="ko-checkbox"
/>
{t("q:match.action.ko")}
</label>
</div>
) : null}
<SendouButton
variant="primary"
isDisabled={!canSubmit || isSubmitting}
onPress={() => {
if (winnerId === null) return;
if (setEnding?.setEndingTeamIds.includes(winnerId)) {
setConfirming(true);
} else {
submit();
}
}}
className={styles.submit}
testId="report-score-button"
>
{t("common:actions.submit")}
</SendouButton>
</div>
)}
{secondaryAction}
</SendouTabPanel>
);
}
function SetEndingConfirmation({
setEnding,
stageId,
mode,
winnerId,
teams,
withPoints,
isKo,
isSubmitting,
onBack,
onConfirm,
}: {
setEnding: SetEndingData;
stageId: StageId;
mode: ModeShort;
winnerId: number;
teams: [ActionTabTeam, ActionTabTeam];
withPoints: boolean;
isKo: boolean;
isSubmitting?: boolean;
onBack: () => void;
onConfirm: () => void;
}) {
const { t } = useTranslation(["q", "common"]);
const winnerSide = winnerId === teams[0].id ? "ALPHA" : "BRAVO";
const newMap: TimelineMap = {
stageId,
mode,
timestamp: Date.now(),
winner: winnerSide,
rosters: setEnding.currentRosters,
points: withPoints
? isKo
? [winnerSide === "ALPHA" ? 100 : 0, winnerSide === "BRAVO" ? 100 : 0]
: [0, 0]
: undefined,
};
const updatedScore = {
alpha: setEnding.score.alpha + (winnerSide === "ALPHA" ? 1 : 0),
bravo: setEnding.score.bravo + (winnerSide === "BRAVO" ? 1 : 0),
};
return (
<div className={styles.confirmationRoot}>
<div className={styles.confirmationMessage}>
{t("q:match.action.confirmSetEnding")}
</div>
<MatchTimeline
teams={setEnding.teams}
score={updatedScore}
maps={[...setEnding.maps, newMap]}
/>
<div className={styles.confirmationButtons}>
<SendouButton
variant="primary"
isDisabled={isSubmitting}
onPress={onConfirm}
testId="confirm-set-end-button"
>
{t("common:actions.confirm")}
</SendouButton>
<SendouButton variant="outlined" onPress={onBack}>
{t("common:actions.back")}
</SendouButton>
</div>
</div>
);
}
function TeamRadioOption({
team,
isOwnTeam,
hideLabel,
className,
testId,
}: {
team: ActionTabTeam;
isOwnTeam: boolean;
hideLabel?: boolean;
className?: string;
testId?: string;
}) {
const { t } = useTranslation(["q"]);
const isLongName = team.name.length > LONG_TEAM_NAME_THRESHOLD;
return (
<Radio
value={String(team.id)}
aria-label={team.name}
className={clsx(styles.teamRadioContainer, className)}
data-testid={testId}
>
{({ isSelected, isFocusVisible }) => (
<span
className={clsx(styles.teamRadio, {
[styles.selected]: isSelected,
[styles.focusVisible]: isFocusVisible,
})}
>
<span
className={clsx(styles.checkCircle, {
[styles.checkCircleSelected]: isSelected,
})}
>
{isSelected ? <Check size={14} /> : null}
</span>
<span className={styles.teamAvatarInfo}>
<Avatar url={team.avatar} identiconInput={team.name} size="xxs" />
<span className={styles.teamInfo}>
<span
className={clsx(styles.teamName, {
[styles.teamNameLong]: isLongName,
})}
>
{team.name}
</span>
{hideLabel ? null : (
<span
className={clsx(styles.teamLabel, {
[styles.myTeamLabel]: isOwnTeam,
[styles.opponentLabel]: !isOwnTeam,
})}
>
{isOwnTeam
? t("q:match.action.myTeam")
: t("q:match.action.opponent")}
</span>
)}
</span>
</span>
</span>
)}
</Radio>
);
}