diff --git a/app/features/settings/components/ModeMapPoolPicker.tsx b/app/features/settings/components/ModeMapPoolPicker.tsx index 5c3d8d6ce..acaa70021 100644 --- a/app/features/settings/components/ModeMapPoolPicker.tsx +++ b/app/features/settings/components/ModeMapPoolPicker.tsx @@ -19,6 +19,7 @@ export function ModeMapPoolPicker({ onChange, modeTabs, onModeChange, + disabled, }: { mode: ModeShort; amountToPick: number; @@ -28,6 +29,8 @@ export function ModeMapPoolPicker({ /** When provided, the divider becomes a tab switcher between these modes. */ modeTabs?: ModeShort[]; onModeChange?: (mode: ModeShort) => void; + /** When true, stages can't be picked or removed (view-only). */ + disabled?: boolean; }) { const [wigglingStageId, setWigglingStageId] = React.useState( null, @@ -114,6 +117,7 @@ export function ModeMapPoolPicker({ const selected = stages.includes(stageId); const onClick = () => { + if (disabled) return; if (isTiebreaker) return; if (banned) return; if (selected) return handlePickedStageClick(stageId); @@ -130,6 +134,7 @@ export function ModeMapPoolPicker({ banned={banned} tiebreaker={isTiebreaker} wiggle={wigglingStageId === stageId} + disabled={disabled} testId={`map-pool-${mode}-${stageId}`} /> ); @@ -158,6 +163,7 @@ function MapButton({ banned, tiebreaker, wiggle, + disabled, testId, }: { stageId: StageId; @@ -166,6 +172,7 @@ function MapButton({ banned?: boolean; tiebreaker?: boolean; wiggle?: boolean; + disabled?: boolean; testId: string; }) { const { t } = useTranslation(["game-misc"]); @@ -181,7 +188,7 @@ function MapButton({ })} style={{ "--map-image-url": `url("${stageImageUrl(stageId)}.avif")` }} onClick={onClick} - disabled={banned} + disabled={disabled || banned} type="button" data-testid={testId} /> diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx index 54742f89b..f04b13b7b 100644 --- a/app/features/tournament/routes/to.$id.register.tsx +++ b/app/features/tournament/routes/to.$id.register.tsx @@ -25,7 +25,6 @@ import { useAutoRerender } from "~/hooks/useAutoRerender"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useHydrated } from "~/hooks/useHydrated"; import { rankedModesShort } from "~/modules/in-game-lists/modes"; -import invariant from "~/utils/invariant"; import { LOG_IN_URL, SENDOU_INK_BASE_URL, @@ -69,9 +68,12 @@ export default function TournamentRegisterPage() { return (
{isRegularMemberOfATeam ? ( -
- {t("tournament:pre.inATeam")} - +
+ {t("tournament:pre.captainOnlyEdit")} +
+ +
+
) : registrationClosedForNonParticipant ? ( {t("tournament:pre.registrationClosed")} @@ -148,11 +150,15 @@ function PleaseLogIn() { ); } -function RegistrationForms() { +function RegistrationForms({ readOnly = false }: { readOnly?: boolean }) { const data = useLoaderData(); const user = useUser(); const tournament = useTournament(); + if (readOnly) { + return ; + } + const ownTeam = tournament.ownedTeamByUser(user); const ownTeamCheckedIn = Boolean(ownTeam && ownTeam.checkIns.length > 0); const hasFriendCodeSet = Boolean(user?.friendCode); @@ -212,6 +218,32 @@ function RegistrationForms() { ); } +function ReadOnlyRegistrationForms() { + const user = useUser(); + const tournament = useTournament(); + + const team = tournament.teamMemberOfByUser(user); + if (!team) return null; + + const checkedIn = team.checkIns.length > 0; + + return ( +
+ + + + {tournament.teamsPrePickMaps ? ( + + ) : null} +
+ ); +} + function RegistrationProgress({ checkedIn, name, @@ -419,16 +451,22 @@ function CheckIn({ function TeamInfo({ ownTeam, canUnregister, + readOnly = false, }: { ownTeam?: TournamentDataTeam | null; canUnregister: boolean; + readOnly?: boolean; }) { const { t } = useTranslation(["tournament", "common"]); const tournament = useTournament(); const defaultValues: Partial = { - teamId: ownTeam?.team ? String(ownTeam.team.id) : null, - pickUpName: ownTeam?.team ? null : (ownTeam?.name ?? ""), + teamId: readOnly ? null : ownTeam?.team ? String(ownTeam.team.id) : null, + pickUpName: readOnly + ? (ownTeam?.name ?? "") + : ownTeam?.team + ? null + : (ownTeam?.name ?? ""), logo: !ownTeam?.team && ownTeam?.pickupAvatarUrl && @@ -488,19 +526,34 @@ function TeamInfo({ className="stack md items-center" submitButtonText={t("common:actions.save")} submitButtonTestId="save-team-button" + readOnly={readOnly} > - +
); } -function RegisterTeamFields() { +function RegisterTeamFields({ readOnly = false }: { readOnly?: boolean }) { const data = useLoaderData(); const tournament = useTournament(); const { values } = useFormFieldContext(); + if (readOnly) { + return ( + <> +
+ +
+
+ +
+ + + ); + } + const isLinked = Boolean(values.teamId); const teamOptions = (data?.teams ?? []).map((team) => ({ @@ -583,12 +636,13 @@ function GoogleFormsLink() { function FillRoster({ ownTeam, ownTeamCheckedIn, + readOnly = false, }: { ownTeam: TournamentDataTeam; ownTeamCheckedIn: boolean; + readOnly?: boolean; }) { const data = useLoaderData(); - const user = useUser(); const tournament = useTournament(); const { copyToClipboard, copySuccess } = useCopyToClipboard(); const { t } = useTranslation(["common", "tournament"]); @@ -598,8 +652,7 @@ function FillRoster({ inviteCode: ownTeam.inviteCode!, })}`; - const { members: ownTeamMembers } = tournament.ownedTeamByUser(user) ?? {}; - invariant(ownTeamMembers, "own team members should exist"); + const ownTeamMembers = ownTeam.members; const missingMembers = Math.max( tournament.minMembersPerTeam - ownTeamMembers.length, @@ -612,11 +665,14 @@ function FillRoster({ ); const showDeleteMemberSection = - (!ownTeamCheckedIn && ownTeamMembers.length > 1) || - (ownTeamCheckedIn && ownTeamMembers.length > tournament.minMembersPerTeam); + !readOnly && + ((!ownTeamCheckedIn && ownTeamMembers.length > 1) || + (ownTeamCheckedIn && + ownTeamMembers.length > tournament.minMembersPerTeam)); const playersAvailableToDirectlyAdd = (() => { - return (data!.friendPlayers?.friends ?? []).filter((user) => { + if (readOnly) return []; + return (data?.friendPlayers?.friends ?? []).filter((user) => { const isNotInTeam = tournament.ctx.teams.every((team) => team.members.every((member) => member.userId !== user.id), ); @@ -629,7 +685,7 @@ function FillRoster({ })(); const teamIsFull = ownTeamMembers.length >= tournament.maxMembersPerTeam; - const canAddMembers = !teamIsFull && tournament.registrationOpen; + const canAddMembers = !teamIsFull && tournament.registrationOpen && !readOnly; return (
@@ -842,13 +898,19 @@ function DeleteMember({ members }: { members: TournamentDataTeam["members"] }) { ); } -function CounterPickMapPoolPicker() { +function CounterPickMapPoolPicker({ + readOnly = false, + mapPool, +}: { + readOnly?: boolean; + mapPool?: NonNullable; +}) { const { t } = useTranslation(["common", "game-misc", "tournament"]); const tournament = useTournament(); const fetcher = useFetcher(); const data = useLoaderData(); const [counterPickMaps, setCounterPickMaps] = React.useState( - data?.mapPool ?? [], + mapPool ?? data?.mapPool ?? [], ); const counterPickMapPool = new MapPool(counterPickMaps); @@ -899,14 +961,15 @@ function CounterPickMapPoolPicker() { ...stageIds.map((stageId) => ({ mode, stageId })), ]) } + disabled={readOnly} /> ); })} - {validateCounterPickMapPool( - counterPickMapPool, - isOneModeTournamentOf, - tournament.ctx.tieBreakerMapPool, - ) === "VALID" ? ( + {readOnly ? null : validateCounterPickMapPool( + counterPickMapPool, + isOneModeTournamentOf, + tournament.ctx.tieBreakerMapPool, + ) === "VALID" ? ( { if (field) return field; @@ -183,7 +184,7 @@ export function FormField({ void} /> @@ -195,7 +196,7 @@ export function FormField({ void} /> @@ -207,7 +208,7 @@ export function FormField({ void} /> @@ -219,7 +220,7 @@ export function FormField({ void} /> @@ -346,7 +347,7 @@ export function FormField({ void} /> diff --git a/app/form/SendouForm.tsx b/app/form/SendouForm.tsx index 50ea0930e..908ccd6f8 100644 --- a/app/form/SendouForm.tsx +++ b/app/form/SendouForm.tsx @@ -37,6 +37,7 @@ export interface FormContextValue { clearServerError: (name: string) => void; onFieldChange?: (name: string, newValue: unknown) => void; hideRequiredIndicator: boolean; + readOnly: boolean; values: Record; setValue: (name: string, value: unknown) => void; setValueFromPrev: (name: string, updater: (prev: unknown) => unknown) => void; @@ -104,6 +105,11 @@ type BaseFormProps = { * adds noise (e.g. the settings page). */ hideRequiredIndicator?: boolean; + /** + * When true, renders the form for viewing only: every field is disabled and + * the submit button is hidden. + */ + readOnly?: boolean; onApply?: (values: z.infer>) => void; secondarySubmit?: React.ReactNode; /** @@ -150,6 +156,7 @@ export function SendouForm({ className, fullWidth, hideRequiredIndicator = false, + readOnly = false, onApply, secondarySubmit, onSuccess, @@ -266,6 +273,7 @@ export function SendouForm({ onFieldChange: autoSubmit || autoApply ? actions.onFieldChange : undefined, hideRequiredIndicator, + readOnly, setValue: actions.setValue, setValueFromPrev: actions.setValueFromPrev, revalidateAll: actions.revalidateAll, @@ -281,6 +289,7 @@ export function SendouForm({ autoSubmit, autoApply, hideRequiredIndicator, + readOnly, fetcher.state, store, actions, @@ -303,7 +312,7 @@ export function SendouForm({ <> {title ?

{title}

: null} {resolvedChildren} - {autoSubmit || autoApply ? null : ( + {autoSubmit || autoApply || readOnly ? null : (