Tournament register page read only for non-captain participants

This commit is contained in:
Kalle
2026-06-18 15:28:16 +03:00
parent 64cc08a52b
commit f2e0c7d36b
20 changed files with 127 additions and 30 deletions

View File

@@ -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<StageId | null>(
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}
/>

View File

@@ -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 (
<div className={clsx("stack lg", containerClassName("normal"))}>
{isRegularMemberOfATeam ? (
<div className="stack md items-center">
<Alert>{t("tournament:pre.inATeam")}</Alert>
<LeaveTeamControl />
<div className="stack md">
<Alert>{t("tournament:pre.captainOnlyEdit")}</Alert>
<div className="stack md items-center">
<LeaveTeamControl />
</div>
<RegistrationForms readOnly />
</div>
) : registrationClosedForNonParticipant ? (
<Alert>{t("tournament:pre.registrationClosed")}</Alert>
@@ -148,11 +150,15 @@ function PleaseLogIn() {
);
}
function RegistrationForms() {
function RegistrationForms({ readOnly = false }: { readOnly?: boolean }) {
const data = useLoaderData<TournamentRegisterPageLoader>();
const user = useUser();
const tournament = useTournament();
if (readOnly) {
return <ReadOnlyRegistrationForms />;
}
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 (
<div className="stack lg">
<RegistrationProgress
checkedIn={checkedIn}
name={team.name}
mapPool={team.mapPool ?? undefined}
members={team.members}
/>
<TeamInfo ownTeam={team} canUnregister={false} readOnly />
<FillRoster ownTeam={team} ownTeamCheckedIn={checkedIn} readOnly />
{tournament.teamsPrePickMaps ? (
<CounterPickMapPoolPicker readOnly mapPool={team.mapPool ?? []} />
) : null}
</div>
);
}
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<RegisterTeamFormValues> = {
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}
>
<RegisterTeamFields />
<RegisterTeamFields readOnly={readOnly} />
</SendouForm>
</section>
</div>
);
}
function RegisterTeamFields() {
function RegisterTeamFields({ readOnly = false }: { readOnly?: boolean }) {
const data = useLoaderData<TournamentRegisterPageLoader>();
const tournament = useTournament();
const { values } = useFormFieldContext();
if (readOnly) {
return (
<>
<div className={styles.sectionInputContainer}>
<FormField name="pickUpName" />
</div>
<div className={styles.sectionInputContainer}>
<FormField name="logo" />
</div>
<FormField name="prefersNotToHost" />
</>
);
}
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<TournamentRegisterPageLoader>();
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 (
<div>
@@ -842,13 +898,19 @@ function DeleteMember({ members }: { members: TournamentDataTeam["members"] }) {
);
}
function CounterPickMapPoolPicker() {
function CounterPickMapPoolPicker({
readOnly = false,
mapPool,
}: {
readOnly?: boolean;
mapPool?: NonNullable<TournamentDataTeam["mapPool"]>;
}) {
const { t } = useTranslation(["common", "game-misc", "tournament"]);
const tournament = useTournament();
const fetcher = useFetcher();
const data = useLoaderData<TournamentRegisterPageLoader>();
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" ? (
<SubmitButton
_action="UPDATE_MAP_POOL"
state={fetcher.state}

View File

@@ -77,6 +77,7 @@ export function FormField({
canRemoveItem,
}: FormFieldProps) {
const context = useOptionalFormFieldContext();
const isDisabled = disabled ?? context?.readOnly ?? false;
const fieldSchema = React.useMemo(() => {
if (field) return field;
@@ -183,7 +184,7 @@ export function FormField({
<InputFormField
{...commonProps}
{...formField}
disabled={disabled}
disabled={isDisabled}
value={value as string}
onChange={handleChange as (v: string) => void}
/>
@@ -195,7 +196,7 @@ export function FormField({
<InGameNameFormField
{...commonProps}
{...formField}
disabled={disabled}
disabled={isDisabled}
value={value as string}
onChange={handleChange as (v: string) => void}
/>
@@ -207,7 +208,7 @@ export function FormField({
<SwitchFormField
{...commonProps}
{...formField}
isDisabled={disabled}
isDisabled={isDisabled}
checked={value as boolean}
onChange={handleChange as (v: boolean) => void}
/>
@@ -219,7 +220,7 @@ export function FormField({
<TextareaFormField
{...commonProps}
{...formField}
disabled={disabled}
disabled={isDisabled}
value={value as string}
onChange={handleChange as (v: string) => void}
/>
@@ -346,7 +347,7 @@ export function FormField({
<ImageFormField
{...commonProps}
{...formField}
disabled={disabled}
disabled={isDisabled}
value={value as ImageFieldValue}
onChange={handleChange as (v: ImageFieldValue) => void}
/>

View File

@@ -37,6 +37,7 @@ export interface FormContextValue<T extends z.ZodRawShape = z.ZodRawShape> {
clearServerError: (name: string) => void;
onFieldChange?: (name: string, newValue: unknown) => void;
hideRequiredIndicator: boolean;
readOnly: boolean;
values: Record<string, unknown>;
setValue: (name: string, value: unknown) => void;
setValueFromPrev: (name: string, updater: (prev: unknown) => unknown) => void;
@@ -104,6 +105,11 @@ type BaseFormProps<T extends z.ZodRawShape> = {
* 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<z.ZodObject<T>>) => void;
secondarySubmit?: React.ReactNode;
/**
@@ -150,6 +156,7 @@ export function SendouForm<T extends z.ZodRawShape>({
className,
fullWidth,
hideRequiredIndicator = false,
readOnly = false,
onApply,
secondarySubmit,
onSuccess,
@@ -266,6 +273,7 @@ export function SendouForm<T extends z.ZodRawShape>({
onFieldChange:
autoSubmit || autoApply ? actions.onFieldChange : undefined,
hideRequiredIndicator,
readOnly,
setValue: actions.setValue,
setValueFromPrev: actions.setValueFromPrev,
revalidateAll: actions.revalidateAll,
@@ -281,6 +289,7 @@ export function SendouForm<T extends z.ZodRawShape>({
autoSubmit,
autoApply,
hideRequiredIndicator,
readOnly,
fetcher.state,
store,
actions,
@@ -303,7 +312,7 @@ export function SendouForm<T extends z.ZodRawShape>({
<>
{title ? <h2 className={styles.title}>{title}</h2> : null}
<React.Fragment key={locationKey}>{resolvedChildren}</React.Fragment>
{autoSubmit || autoApply ? null : (
{autoSubmit || autoApply || readOnly ? null : (
<div className="mt-4 stack horizontal md mx-auto justify-center items-center">
<SubmitButton
_action={_action}
@@ -673,6 +682,7 @@ export function useFormFieldContext(): FormContextValue {
clearServerError: context.clearServerError,
onFieldChange: context.onFieldChange,
hideRequiredIndicator: context.hideRequiredIndicator,
readOnly: context.readOnly,
values,
setValue: context.setValue,
setValueFromPrev: context.setValueFromPrev,

View File

@@ -31,6 +31,7 @@
"pre.footer": "Tilmeldingen til turneringen kan frit ændres inden turneringen starter",
"pre.logIn": "Log in for at tilmelde dig",
"pre.inATeam": "Du er allerede en del af et hold til denne begivenhed",
"pre.captainOnlyEdit": "",
"pre.registrationClosed": "",
"pre.checkIn.range": "Tjek-ind kan gøres imellem {{start}} og {{finish}}",
"pre.checkIn.over": "Tjek-ind er forbi",

View File

@@ -31,6 +31,7 @@
"pre.footer": "Registrierung kann vor Turnierstart frei verändert werden",
"pre.logIn": "Logge dich zum Registrieren ein",
"pre.inATeam": "Du bist in einem Team für dieses Event",
"pre.captainOnlyEdit": "",
"pre.registrationClosed": "",
"pre.checkIn.range": "Check-in ist zwischen {{start}} und {{finish}} geöffnet",
"pre.checkIn.over": "Check-in ist vorbei",

View File

@@ -31,6 +31,7 @@
"pre.footer": "Registration can be freely changed before the tournament starts",
"pre.logIn": "Log in to register",
"pre.inATeam": "You are in a team for this event",
"pre.captainOnlyEdit": "You are in a team for this event. Only the team captain can edit the registration.",
"pre.registrationClosed": "Registration for this tournament has closed",
"pre.checkIn.range": "Check-in is open between {{start}} and {{finish}}",
"pre.checkIn.over": "Check-in is over",

View File

@@ -31,6 +31,7 @@
"pre.footer": "Registro puede ser ajustado antes de que empieze el torneo",
"pre.logIn": "Ingresa al sitio para registrar",
"pre.inATeam": "Estás en un equipo para este evento",
"pre.captainOnlyEdit": "",
"pre.registrationClosed": "",
"pre.checkIn.range": "El check-in está abierto entre {{start}} y {{finish}}",
"pre.checkIn.over": "Se acabó check-in",

View File

@@ -31,6 +31,7 @@
"pre.footer": "Registro puede ser ajustado antes de que empieze el torneo",
"pre.logIn": "Ingresa al sitio para registrar",
"pre.inATeam": "Estás en un equipo para este evento",
"pre.captainOnlyEdit": "",
"pre.registrationClosed": "",
"pre.checkIn.range": "El check-in está abierto entre {{start}} y {{finish}}",
"pre.checkIn.over": "Se acabó check-in",

View File

@@ -31,6 +31,7 @@
"pre.footer": "L'inscription peut être librement modifiée avant le début du tournoi",
"pre.logIn": "Connectez-vous pour vous inscrire",
"pre.inATeam": "Vous faites partie d'une équipe pour cet événement",
"pre.captainOnlyEdit": "",
"pre.registrationClosed": "",
"pre.checkIn.range": "L'enregistrement est ouvert entre {{start}} et {{finish}}",
"pre.checkIn.over": "L'enregistrement est fini",

View File

@@ -31,6 +31,7 @@
"pre.footer": "L'inscription peut être librement modifiée avant le début du tournoi",
"pre.logIn": "Connectez-vous pour vous inscrire",
"pre.inATeam": "Vous faites partie d'une équipe pour cet événement",
"pre.captainOnlyEdit": "",
"pre.registrationClosed": "",
"pre.checkIn.range": "L'enregistrement est ouvert entre {{start}} et {{finish}}",
"pre.checkIn.over": "L'enregistrement est fini",

View File

@@ -31,6 +31,7 @@
"pre.footer": "הרשמה ניתנת לשינוי עד שהטורניר מתחיל",
"pre.logIn": "התחבר כדי להירשם",
"pre.inATeam": "הנך בצוות לאירוע",
"pre.captainOnlyEdit": "",
"pre.registrationClosed": "",
"pre.checkIn.range": "צ'ק-אין פתוח בין {{start}} ל-{{finish}}",
"pre.checkIn.over": "צ'ק-אין נגמר",

View File

@@ -31,6 +31,7 @@
"pre.footer": "L'iscrizione può essere cambiata liberamente prima del torneo",
"pre.logIn": "Accedi per iscriverti",
"pre.inATeam": "Sei in un team per questo evento",
"pre.captainOnlyEdit": "",
"pre.registrationClosed": "",
"pre.checkIn.range": "Il Check-in è aperto tra {{start}} e {{finish}}",
"pre.checkIn.over": "Il Check-in è chiuso",

View File

@@ -31,6 +31,7 @@
"pre.footer": "参加登録はトーナメント開始前であればいつでも変更できます",
"pre.logIn": "ログインして登録する",
"pre.inATeam": "あなたはこのイベントでチームに参加しています",
"pre.captainOnlyEdit": "",
"pre.registrationClosed": "",
"pre.checkIn.range": "チェックインは {{start}} から {{finish}} までのあいだ受け付けています",
"pre.checkIn.over": "チェックインは終了しました",

View File

@@ -31,6 +31,7 @@
"pre.footer": "",
"pre.logIn": "",
"pre.inATeam": "",
"pre.captainOnlyEdit": "",
"pre.registrationClosed": "",
"pre.checkIn.range": "",
"pre.checkIn.over": "",

View File

@@ -31,6 +31,7 @@
"pre.footer": "",
"pre.logIn": "",
"pre.inATeam": "",
"pre.captainOnlyEdit": "",
"pre.registrationClosed": "",
"pre.checkIn.range": "",
"pre.checkIn.over": "",

View File

@@ -31,6 +31,7 @@
"pre.footer": "",
"pre.logIn": "",
"pre.inATeam": "",
"pre.captainOnlyEdit": "",
"pre.registrationClosed": "",
"pre.checkIn.range": "",
"pre.checkIn.over": "",

View File

@@ -31,6 +31,7 @@
"pre.footer": "O registro pode ser livremente alterado antes do torneio começar",
"pre.logIn": "Faça o login para registrar",
"pre.inATeam": "Você está em um time para este evento",
"pre.captainOnlyEdit": "",
"pre.registrationClosed": "",
"pre.checkIn.range": "O check-in está aberto entre {{start}} e {{finish}}",
"pre.checkIn.over": "O check-in acabou",

View File

@@ -31,6 +31,7 @@
"pre.footer": "До начала турнира вся информация о команде может быть изменена",
"pre.logIn": "Войдите, чтобы зарегистрироваться",
"pre.inATeam": "Вы уже состоите в команде, записанной на этот турнир",
"pre.captainOnlyEdit": "",
"pre.registrationClosed": "",
"pre.checkIn.range": "Чек-ин открыт с {{start}} по {{finish}}",
"pre.checkIn.over": "Чек-ин закрыт",

View File

@@ -31,6 +31,7 @@
"pre.footer": "在比赛开始前,报名信息可随意改动",
"pre.logIn": "登录以报名",
"pre.inATeam": "您已经在本次活动的某支队伍里了",
"pre.captainOnlyEdit": "",
"pre.registrationClosed": "",
"pre.checkIn.range": "签到开放时间为 {{start}} 至 {{finish}}",
"pre.checkIn.over": "签到已结束",