Improve register page UI

This commit is contained in:
Kalle
2026-08-23 17:55:53 +03:00
parent 9fe28c4f48
commit ea1db08972
48 changed files with 953 additions and 485 deletions

View File

@@ -0,0 +1,10 @@
.row {
display: flex;
align-items: center;
gap: var(--s-2);
& input {
flex: 1;
min-width: 0;
}
}

View File

@@ -0,0 +1,45 @@
import { Check, Clipboard } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { SendouButton } from "~/components/elements/Button";
import { Label } from "~/components/Label";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import styles from "./InviteLinkInput.module.css";
/** A labeled read-only invite link with a copy to clipboard button. */
export function InviteLinkInput({
link,
label,
}: {
link: string;
/** Overrides the default "Invite link" label. */
label?: string;
}) {
const { t } = useTranslation(["common"]);
const id = React.useId();
const { copyToClipboard, copySuccess } = useCopyToClipboard();
return (
<div>
<Label htmlFor={id}>{label ?? t("common:inviteLink")}</Label>
<div className={styles.row}>
<input
type="text"
value={link}
readOnly
id={id}
onFocus={(e) => e.currentTarget.select()}
data-testid="invite-link-input"
/>
<SendouButton
shape="square"
variant={copySuccess ? "outlined-success" : "outlined"}
onPress={() => copyToClipboard(link)}
icon={copySuccess ? <Check /> : <Clipboard />}
aria-label={t("common:actions.copyToClipboard")}
data-testid="copy-invite-link-button"
/>
</div>
</div>
);
}

View File

@@ -9,6 +9,11 @@
&[data-placeholder] {
color: var(--color-text-high);
}
/* two-line items render only their label line inside the trigger */
& [slot="description"] {
display: none;
}
}
.item {

View File

@@ -12,6 +12,7 @@
gap: var(--s-1-5);
width: 100%;
cursor: pointer;
text-align: start;
&[data-focus-visible],
&[aria-expanded="true"] {
@@ -44,6 +45,9 @@
display: flex;
flex-direction: column;
/* virtualized lists size from their container, so the popover cannot size from content */
min-width: var(--trigger-width);
}
.listBox {
@@ -51,6 +55,11 @@
flex: 1;
}
.item {
cursor: pointer;
outline: none;
}
.itemFocused {
background-color: var(--color-bg-high);
color: var(--color-text);

View File

@@ -116,7 +116,7 @@ export function SelectShellItem({
<ListBoxItem
{...rest}
className={({ isFocused, isSelected }) =>
clsx(className, {
clsx(className, styles.item, {
[styles.itemFocused]: isFocused,
[styles.itemSelected]: isSelected,
})

View File

@@ -1,9 +1,11 @@
.panel {
display: flex;
flex-direction: column;
gap: var(--s-2-5);
padding: var(--s-3);
background-color: var(--color-bg-high);
gap: var(--s-3);
width: 100%;
padding: var(--s-4);
background-color: var(--color-bg);
border: var(--border-style);
border-radius: var(--radius-box);
font-size: var(--font-xs);
}
@@ -13,7 +15,8 @@
color: var(--color-text);
}
.headingWindow {
.windowText {
font-size: var(--font-xs);
font-weight: var(--weight-body);
color: var(--color-text-high);
}
@@ -21,7 +24,7 @@
.rows {
display: flex;
flex-direction: column;
gap: var(--s-1-5);
gap: var(--s-2-5);
list-style: none;
padding: 0;
margin: 0;
@@ -29,22 +32,80 @@
.row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-1-5);
gap: var(--s-2);
min-width: 0;
font-size: var(--font-xs);
}
& > svg {
flex-shrink: 0;
.statusCircle {
width: 24px;
height: 24px;
flex-shrink: 0;
display: grid;
place-items: center;
border-radius: var(--radius-full);
background-color: color-mix(in oklch, var(--color-text) 8%, transparent);
&[data-status="available"] {
background-color: color-mix(
in oklch,
var(--color-success) 20%,
transparent
);
}
&[data-status="partial"] {
background-color: color-mix(
in oklch,
var(--color-warning) 20%,
transparent
);
}
&[data-status="unavailable"],
&[data-status="busy"] {
background-color: color-mix(in oklch, var(--color-error) 15%, transparent);
}
}
.nameBlock {
display: flex;
flex-direction: column;
min-width: 0;
}
.name {
font-weight: var(--weight-semi);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.secondaryName {
font-size: var(--font-3xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trailing {
margin-inline-start: auto;
display: inline-flex;
align-items: center;
}
.ranges {
color: var(--color-text-high);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.detailText {
color: var(--color-text-high);
white-space: nowrap;
}
.mutedText {
@@ -96,6 +157,27 @@
color: var(--color-text);
}
.dots {
display: inline-flex;
align-items: center;
gap: var(--s-1);
}
.dot {
width: 8px;
height: 8px;
border-radius: var(--radius-full);
flex-shrink: 0;
&[data-status="available"] {
background-color: var(--color-success);
}
&[data-status="partial"] {
background-color: var(--color-warning);
}
}
.subsSection {
display: flex;
flex-direction: column;

View File

@@ -1,12 +1,14 @@
import clsx from "clsx";
import {
CalendarX,
Check,
Clock,
Ellipsis,
EyeOff,
Flag,
HelpCircle,
X,
} from "lucide-react";
import type * as React from "react";
import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
@@ -16,7 +18,7 @@ import type { TimeRange } from "../availability-types";
import type { RegistrationAvailability } from "../core/RegistrationAvailability.server";
import styles from "./RegistrationAvailabilityPanel.module.css";
interface PanelUser {
export interface AvailabilityPanelUser {
id: number;
username: string;
discordId: string;
@@ -24,8 +26,24 @@ interface PanelUser {
customAvatarUrl?: string | null;
}
type PanelData = SerializeFrom<RegistrationAvailability>;
type PanelEntry = NonNullable<PanelData["entries"]>[number];
export type AvailabilityPanelData = SerializeFrom<RegistrationAvailability>;
export type AvailabilityPanelEntry = NonNullable<
AvailabilityPanelData["entries"]
>[number];
export type AvailabilityRowStatus =
| AvailabilityPanelEntry["availability"]["status"]
/** On the roster, but their schedule is not visible to the viewer (neither a teammate nor a friend). */
| "hidden";
const STATUS_ORDER: Array<AvailabilityRowStatus> = [
"available",
"partial",
"unavailable",
"busy",
"unknown",
"hidden",
];
/**
* The tournament registration page's availability panel: how each member of
@@ -37,19 +55,12 @@ export function RegistrationAvailabilityPanel({
roster,
subCandidates,
}: {
availability: PanelData;
roster: Array<PanelUser>;
availability: AvailabilityPanelData;
roster: Array<AvailabilityPanelUser>;
/** Friends not on the shown roster and not in the tournament, panel keeps the free ones. */
subCandidates: Array<PanelUser>;
subCandidates: Array<AvailabilityPanelUser>;
}) {
const { t } = useTranslation(["schedule"]);
const { formatter: windowFormatter } = useDateTimeFormat({
weekday: "short",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
const { formatter: dateFormatter } = useDateTimeFormat({
month: "long",
day: "numeric",
@@ -77,84 +88,138 @@ export function RegistrationAvailabilityPanel({
return status === "available" || status === "partial";
});
if (roster.length === 0 && freeSubs.length === 0) return null;
return (
<section className={styles.panel}>
<h4 className={styles.heading}>
{t("schedule:registration.title")} ·{" "}
<span className={styles.headingWindow}>
{windowFormatter.formatRange(
availability.window.startsAt,
availability.window.endsAt,
)}{" "}
({t("schedule:registration.estimated")})
</span>
<AvailabilityWindowText window={availability.window} />
</h4>
<ul className={styles.rows}>
{roster.map((user) => (
<MemberRow
key={user.id}
user={user}
entry={entryByUserId.get(user.id)}
/>
))}
</ul>
<SummaryLine roster={roster} entryByUserId={entryByUserId} />
{freeSubs.length > 0 ? (
<div className={styles.subsSection}>
<h5 className={styles.subsHeading}>
{t("schedule:registration.friends")}
</h5>
{roster.length > 0 ? (
<>
<ul className={styles.rows}>
{freeSubs.map((user) => (
<MemberRow
{roster.map((user) => (
<AvailabilityMemberRow
key={user.id}
user={user}
entry={entryByUserId.get(user.id)}
/>
))}
</ul>
</div>
<AvailabilitySummary
statuses={roster.map((user) =>
availabilityRowStatus(entryByUserId.get(user.id)),
)}
/>
</>
) : null}
{freeSubs.length > 0 ? (
roster.length > 0 ? (
<div className={styles.subsSection}>
<h5 className={styles.subsHeading}>
{t("schedule:registration.friends")}
</h5>
<ul className={styles.rows}>
{freeSubs.map((user) => (
<AvailabilityMemberRow
key={user.id}
user={user}
entry={entryByUserId.get(user.id)}
/>
))}
</ul>
</div>
) : (
<ul className={styles.rows}>
{freeSubs.map((user) => (
<AvailabilityMemberRow
key={user.id}
user={user}
entry={entryByUserId.get(user.id)}
/>
))}
</ul>
)
) : null}
</section>
);
}
function MemberRow({ user, entry }: { user: PanelUser; entry?: PanelEntry }) {
/**
* One user's availability as a list row: status icon, avatar, name and the
* availability detail. The registration page composes it with roster extras
* (an in-game name line, a remove button).
*/
export function AvailabilityMemberRow({
user,
entry,
showAvailability = true,
primaryName,
secondaryName,
trailing,
nameTestId,
}: {
user: AvailabilityPanelUser;
entry?: AvailabilityPanelEntry;
/** Set false when there is no availability data for the event (e.g. leagues), keeping just avatar + name. */
showAvailability?: boolean;
primaryName?: string;
secondaryName?: string;
trailing?: React.ReactNode;
nameTestId?: string;
}) {
const status = availabilityRowStatus(entry);
return (
<li
className={styles.row}
data-testid={`availability-row-${user.id}`}
data-status={rowStatus(entry)}
data-status={showAvailability ? status : undefined}
>
<StatusIcon status={rowStatus(entry)} />
{showAvailability ? <StatusIcon status={status} /> : null}
<Avatar user={user} size="xxs" />
<span className={styles.name}>{user.username}</span>
<RowDetail entry={entry} />
{entry?.notes.map((note) => (
<span key={note} className={styles.note}>
<Flag size={12} className={styles.noteFlag} /> {note}
</span>
))}
<span className={styles.nameBlock} data-testid={nameTestId}>
<span className={styles.name}>{primaryName ?? user.username}</span>
{secondaryName ? (
<span className={styles.secondaryName}>{secondaryName}</span>
) : null}
</span>
{showAvailability ? <AvailabilityRowDetail entry={entry} /> : null}
{showAvailability
? entry?.notes.map((note) => (
<span key={note} className={styles.note}>
<Flag size={12} className={styles.noteFlag} /> {note}
</span>
))
: null}
{trailing ? <span className={styles.trailing}>{trailing}</span> : null}
</li>
);
}
type RowStatus =
| PanelEntry["availability"]["status"]
/** On the roster, but their schedule is not visible to the viewer (neither a teammate nor a friend). */
| "hidden";
function rowStatus(entry?: PanelEntry): RowStatus {
/**
* Resolves the shown status for a roster member; no entry at all means their
* schedule is not visible to the viewer.
*/
export function availabilityRowStatus(
entry?: AvailabilityPanelEntry,
): AvailabilityRowStatus {
return entry?.availability.status ?? "hidden";
}
function RowDetail({ entry }: { entry?: PanelEntry }) {
/** The availability detail text of one user: free ranges, a busy block or a muted explanation. */
export function AvailabilityRowDetail({
entry,
}: {
entry?: AvailabilityPanelEntry;
}) {
const { t } = useTranslation(["schedule"]);
// xxx: is this what we want?
if (!entry) {
return (
<span className={styles.mutedText}>
<span className={styles.detailText}>
{t("schedule:registration.notVisible")}
</span>
);
@@ -168,13 +233,13 @@ function RowDetail({ entry }: { entry?: PanelEntry }) {
return <RangesText ranges={availability.ranges} />;
case "unavailable":
return (
<span className={styles.mutedText}>
<span className={styles.detailText}>
{t("schedule:team.notAvailable")}
</span>
);
case "unknown":
return (
<span className={styles.mutedText}>
<span className={styles.detailText}>
{t("schedule:team.noSchedule")}
</span>
);
@@ -189,6 +254,79 @@ function RowDetail({ entry }: { entry?: PanelEntry }) {
}
}
/** The event's estimated window as a localized time range, e.g. "Tue, Aug 25, 10:32 AM 2:32 PM (estimated)". */
export function AvailabilityWindowText({
window,
}: {
window: NonNullable<AvailabilityPanelData["window"]>;
}) {
const { t } = useTranslation(["schedule"]);
const { formatter } = useDateTimeFormat({
weekday: "short",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
return (
<span className={styles.windowText}>
{formatter.formatRange(window.startsAt, window.endsAt)} (
{t("schedule:registration.estimated")})
</span>
);
}
/** Counts by status, e.g. "2 available · 1 partial · 1 out". */
export function AvailabilitySummary({
statuses,
className,
}: {
statuses: Array<AvailabilityRowStatus>;
className?: string;
}) {
const { t } = useTranslation(["schedule"]);
const counts = { available: 0, partial: 0, out: 0, unknown: 0 };
for (const status of statuses) {
if (status === "available") counts.available++;
else if (status === "partial") counts.partial++;
else if (status === "unavailable" || status === "busy") counts.out++;
else counts.unknown++;
}
const parts = (["available", "partial", "out", "unknown"] as const).flatMap(
(key) =>
counts[key] > 0
? [t(`schedule:registration.summary.${key}`, { amount: counts[key] })]
: [],
);
return (
<span className={clsx(styles.summary, className)}>{parts.join(" · ")}</span>
);
}
/** A green dot per available member and a yellow dot per partially available one; other statuses show no dot. */
export function AvailabilityStatusDots({
statuses,
}: {
statuses: Array<AvailabilityRowStatus>;
}) {
const shown = statuses
.filter((status) => status === "available" || status === "partial")
.sort((a, b) => STATUS_ORDER.indexOf(a) - STATUS_ORDER.indexOf(b));
if (shown.length === 0) return null;
return (
<span className={styles.dots}>
{shown.map((status, i) => (
<span key={i} className={styles.dot} data-status={status} />
))}
</span>
);
}
function RangesText({ ranges }: { ranges: Array<TimeRange> }) {
const { formatter: timeFormatter } = useDateTimeFormat({
hour: "numeric",
@@ -209,47 +347,39 @@ function RangesText({ ranges }: { ranges: Array<TimeRange> }) {
);
}
function StatusIcon({ status }: { status: RowStatus }) {
function StatusIcon({ status }: { status: AvailabilityRowStatus }) {
return (
<span className={styles.statusCircle} data-status={status}>
{statusGlyph(status)}
</span>
);
}
function statusGlyph(status: AvailabilityRowStatus) {
switch (status) {
case "available":
return <Check size={16} className={styles.iconAvailable} />;
return (
<Check size={15} strokeWidth={3} className={styles.iconAvailable} />
);
case "partial":
return <Clock size={14} className={styles.iconPartial} />;
return <Clock size={13} strokeWidth={3} className={styles.iconPartial} />;
case "unavailable":
return <X size={16} className={styles.iconUnavailable} />;
return <X size={15} strokeWidth={3} className={styles.iconUnavailable} />;
case "busy":
return <CalendarX size={14} className={styles.iconUnavailable} />;
return (
<CalendarX
size={13}
strokeWidth={3}
className={styles.iconUnavailable}
/>
);
case "unknown":
return <HelpCircle size={14} className={styles.iconUnknown} />;
return (
<Ellipsis size={15} strokeWidth={3} className={styles.iconUnknown} />
);
case "hidden":
return <EyeOff size={14} className={styles.iconUnknown} />;
return (
<EyeOff size={13} strokeWidth={3} className={styles.iconUnknown} />
);
}
}
function SummaryLine({
roster,
entryByUserId,
}: {
roster: Array<PanelUser>;
entryByUserId: Map<number, PanelEntry>;
}) {
const { t } = useTranslation(["schedule"]);
const counts = { available: 0, partial: 0, out: 0, unknown: 0 };
for (const user of roster) {
const status = rowStatus(entryByUserId.get(user.id));
if (status === "available") counts.available++;
else if (status === "partial") counts.partial++;
else if (status === "unavailable" || status === "busy") counts.out++;
else counts.unknown++;
}
const parts = (["available", "partial", "out", "unknown"] as const).flatMap(
(key) =>
counts[key] > 0
? [t(`schedule:registration.summary.${key}`, { amount: counts[key] })]
: [],
);
return <div className={styles.summary}>{parts.join(" · ")}</div>;
}

View File

@@ -1,7 +1,6 @@
import { sub } from "date-fns";
import {
Check,
Clipboard,
Eye,
EyeOff,
Map as MapIcon,
@@ -29,6 +28,7 @@ import {
SendouTabPanel,
SendouTabs,
} from "~/components/elements/Tabs";
import { InviteLinkInput } from "~/components/InviteLinkInput";
import { LocaleTimeRange } from "~/components/LocaleTimeRange";
import { useUser } from "~/features/auth/core/user";
import { useTopicRevalidation } from "~/features/chat/chat-hooks";
@@ -37,7 +37,6 @@ import {
TournamentProvider,
useTournament,
} from "~/features/tournament/tournament-context";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import { useHydrated } from "~/hooks/useHydrated";
import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect";
import { useSearchParam } from "~/modules/search-params/hooks";
@@ -438,7 +437,6 @@ function MapPreparer({
function AddSubsPopOver() {
const { t } = useTranslation(["common", "tournament"]);
const { copyToClipboard, copySuccess } = useCopyToClipboard();
const tournament = useTournament();
const user = useUser();
const data = useLoaderData<TournamentBracketsLoaderData>();
@@ -465,19 +463,7 @@ function AddSubsPopOver() {
{subsAvailableToAdd > 0 ? (
<>
<Divider className="my-2" />
<div>{t("tournament:actions.shareLink", { inviteLink })}</div>
<div className="mt-2 flex justify-center">
<SendouButton
size="small"
icon={copySuccess ? <Check /> : <Clipboard />}
onPress={() => copyToClipboard(inviteLink)}
variant="minimal"
className="tiny"
data-testid="copy-invite-link-button"
>
{t("common:actions.copyToClipboard")}
</SendouButton>
</div>
<InviteLinkInput link={inviteLink} />
</>
) : null}
</SubsPopover>

View File

@@ -8,8 +8,10 @@
padding: var(--s-4) var(--s-3);
}
.sectionInputContainer {
width: 16rem;
.sectionForm {
width: 100%;
max-width: 26rem;
margin-inline: auto;
}
.sectionWarning {
@@ -19,42 +21,104 @@
color: var(--color-text-high);
}
.rosterGrid {
display: grid;
grid-template-columns: repeat(auto-fill, 110px);
gap: var(--s-4);
margin-block-start: var(--s-2);
width: 100%;
justify-content: center;
.teamOptionAvailability {
display: inline-flex;
align-items: center;
gap: var(--s-2);
font-weight: var(--weight-body);
}
.rosterGridMemberName {
max-width: 110px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.rosterMutedNote {
font-size: var(--font-xs);
color: var(--color-text-high);
}
.missingPlayer {
width: 62px;
height: 62px;
font-size: 32px;
border-radius: 100%;
border: var(--border-style-accent);
.rosterRows {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
& > li {
padding-block: var(--s-2);
& + li {
border-top: 1px solid var(--color-border);
}
}
}
.emptySlotRow {
display: flex;
align-items: center;
gap: var(--s-1-5);
font-size: var(--font-xs);
font-weight: var(--weight-semi);
color: var(--color-text-accent);
}
.emptySlotRowOptional {
color: var(--color-text-high);
}
.emptySlotCircle {
width: 24px;
height: 24px;
flex-shrink: 0;
display: grid;
place-items: center;
margin: 0 auto;
border-radius: var(--radius-full);
border: var(--border-style-accent);
color: var(--color-text-accent);
}
.missingPlayerOptional {
border: 2px dashed var(--color-text-accent);
color: var(--color-text-accent);
.emptySlotCircleOptional {
border: var(--border-width) dashed var(--color-text-accent);
}
.addMembers {
border-top: 1px solid var(--color-border);
padding-top: var(--s-3);
}
.addMembersHeading {
font-size: var(--font-xs);
color: var(--color-text-high);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.quickAddRow {
display: flex;
align-items: flex-end;
gap: var(--s-2);
}
.quickAddSelect {
flex: 1;
min-width: 0;
}
.quickAddItem {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
min-width: 0;
}
.quickAddItemAvailability {
display: inline-flex;
align-items: center;
gap: var(--s-1-5);
font-size: var(--font-xs);
font-weight: var(--weight-body);
}
@container (width >= 640px) {
.section {
margin: 0;
border-radius: var(--radius-box);
padding: var(--s-6) var(--s-5);
}
}

View File

@@ -1,23 +1,33 @@
import clsx from "clsx";
import { AlertCircle, Check, Clipboard, X } from "lucide-react";
import { AlertCircle, Check, UserRound, X } from "lucide-react";
import * as React from "react";
import { Text } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { useFetcher, useLoaderData } from "react-router";
import * as R from "remeda";
import { ActionButton } from "~/components/ActionButton";
import { Alert } from "~/components/Alert";
import { Avatar } from "~/components/Avatar";
import { Divider } from "~/components/Divider";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { SendouSelect, SendouSelectItem } from "~/components/elements/Select";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { FriendCodePopover } from "~/components/FriendCodePopover";
import { Label } from "~/components/Label";
import { InviteLinkInput } from "~/components/InviteLinkInput";
import { containerClassName } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { Config } from "~/config";
import { useUser } from "~/features/auth/core/user";
import { RegistrationAvailabilityPanel } from "~/features/availability/components/RegistrationAvailabilityPanel";
import {
AvailabilityMemberRow,
type AvailabilityPanelEntry,
AvailabilityRowDetail,
type AvailabilityRowStatus,
AvailabilityStatusDots,
AvailabilitySummary,
AvailabilityWindowText,
availabilityRowStatus,
RegistrationAvailabilityPanel,
} from "~/features/availability/components/RegistrationAvailabilityPanel";
import { timezoneMiddleware } from "~/features/timezone/timezone-middleware.server";
import {
type CounterPickMapPool,
@@ -33,7 +43,6 @@ import { FormField } from "~/form/FormField";
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useAutoRerender } from "~/hooks/useAutoRerender";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import { useHydrated } from "~/hooks/useHydrated";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
@@ -51,7 +60,6 @@ import {
import {
addPlayerSchema,
checkInSchema,
deleteTeamMemberSchema,
updateMapPoolSchema,
} from "../tournament-schemas";
import type { Route } from "./+types/to.$id.register";
@@ -59,6 +67,15 @@ import styles from "./to.$id.register.module.css";
export { action, loader };
const QUICK_ADD_STATUS_ORDER: Record<AvailabilityRowStatus, number> = {
available: 0,
partial: 1,
unknown: 2,
hidden: 3,
busy: 4,
unavailable: 5,
};
export const middleware: Route.MiddlewareFunction[] = [timezoneMiddleware];
export const handle: SendouRouteHandle = {
@@ -224,7 +241,6 @@ function RegistrationForms({ readOnly = false }: { readOnly?: boolean }) {
)}
/>
) : null}
{ownTeam ? <TournamentRosterAvailability ownTeam={ownTeam} /> : null}
{tournament.isLeague &&
tournament.ctx.organization?.id === LUTI_ORGANIZATION_ID ? (
<GoogleFormsLink />
@@ -259,7 +275,6 @@ function ReadOnlyRegistrationForms() {
members={team.members}
/>
<TeamInfo ownTeam={team} canUnregister={false} readOnly />
<TournamentRosterAvailability ownTeam={team} />
<FillRoster ownTeam={team} ownTeamCheckedIn={checkedIn} readOnly />
{tournament.teamsPrePickMaps ? (
<TeamCounterPickMapPoolPicker readOnly mapPool={team.mapPool ?? []} />
@@ -544,7 +559,7 @@ function TeamInfo({
<SendouForm
schema={registerTeamFormSchema}
defaultValues={defaultValues}
className="stack md items-center"
className={clsx("stack md", styles.sectionForm)}
submitButtonText={t("common:actions.save")}
submitButtonTestId="save-team-button"
readOnly={readOnly}
@@ -564,12 +579,8 @@ function RegisterTeamFields({ readOnly = false }: { readOnly?: boolean }) {
if (readOnly) {
return (
<>
<div className={styles.sectionInputContainer}>
<FormField name="pickUpName" />
</div>
<div className={styles.sectionInputContainer}>
<FormField name="logo" />
</div>
<FormField name="pickUpName" />
<FormField name="logo" />
<FormField name="prefersNotToHost" />
</>
);
@@ -577,31 +588,42 @@ function RegisterTeamFields({ readOnly = false }: { readOnly?: boolean }) {
const isLinked = Boolean(values.teamId);
const teamOptions = (data?.teams ?? []).map((team) => ({
value: String(team.id),
label: team.name,
}));
const entryByUserId = availabilityEntryByUserId(data);
const teamOptions = (data?.teams ?? []).map((team) => {
const statuses = (
entryByUserId
? teamMemberStatuses({ data, teamId: team.id, entryByUserId })
: []
).filter((status) => status === "available" || status === "partial");
return {
value: String(team.id),
label: team.name,
description:
statuses.length > 0 ? (
<span className={styles.teamOptionAvailability}>
<AvailabilityStatusDots statuses={statuses} />
<AvailabilitySummary statuses={statuses} />
</span>
) : undefined,
};
});
const showTeamSelect = teamOptions.length > 0 && tournament.registrationOpen;
return (
<>
{showTeamSelect ? (
<div className={styles.sectionInputContainer}>
<FormField name="teamId" options={teamOptions} />
</div>
<FormField name="teamId" options={teamOptions} />
) : null}
{!data?.ownTeam ? <SelectedTeamAvailability /> : null}
{!isLinked ? (
<>
<div className={styles.sectionInputContainer}>
<FormField
name="pickUpName"
disabled={!tournament.registrationOpen}
/>
</div>
<div className={styles.sectionInputContainer}>
<FormField name="logo" />
</div>
<FormField
name="pickUpName"
disabled={!tournament.registrationOpen}
/>
<FormField name="logo" />
</>
) : null}
<FormField name="prefersNotToHost" />
@@ -667,8 +689,11 @@ function FillRoster({
}) {
const data = useLoaderData<TournamentRegisterPageLoader>();
const tournament = useTournament();
const { copyToClipboard, copySuccess } = useCopyToClipboard();
const { t } = useTranslation(["common", "tournament"]);
const { t } = useTranslation(["common", "tournament", "schedule"]);
const { formatter: dateFormatter } = useDateTimeFormat({
month: "long",
day: "numeric",
});
const inviteLink = `${SENDOU_INK_BASE_URL}${tournamentJoinPage({
tournamentId: tournament.ctx.id,
@@ -687,16 +712,19 @@ function FillRoster({
0,
);
const showDeleteMemberSection =
const canRemoveMembers =
!readOnly &&
!tournament.isInvitational &&
((!ownTeamCheckedIn && ownTeamMembers.length > 1) ||
(ownTeamCheckedIn &&
ownTeamMembers.length > tournament.minMembersPerTeam));
const playersAvailableToDirectlyAdd = (() => {
const quickAddPlayers = (() => {
if (readOnly) return [];
return (data?.friendPlayers?.friends ?? []).filter((user) => {
return R.uniqueBy(
data?.friendPlayers?.friends ?? [],
(friend) => friend.id,
).filter((user) => {
const isNotInTeam = tournament.ctx.teams.every(
(team) => !team.memberUserIds.includes(user.id),
);
@@ -711,89 +739,105 @@ function FillRoster({
const teamIsFull = ownTeamMembers.length >= tournament.maxMembersPerTeam;
const canAddMembers = !teamIsFull && tournament.registrationOpen && !readOnly;
const availability = data?.availability;
const entryByUserId = availabilityEntryByUserId(data);
const requireInGameNames = tournament.ctx.settings.requireInGameNames;
return (
<div>
<h3 className={styles.sectionHeader}>
2. {t("tournament:pre.roster.header")}
</h3>
<section className={clsx(styles.section, "stack lg items-center")}>
{playersAvailableToDirectlyAdd.length > 0 && canAddMembers ? (
<>
<DirectlyAddPlayerSelect
players={playersAvailableToDirectlyAdd}
teams={data!.friendPlayers?.teams ?? []}
/>
<Divider className="text-uppercase">{t("common:or")}</Divider>
</>
<div className="stack xs horizontal justify-between items-end flex-wrap">
<h3 className={styles.sectionHeader}>
2. {t("tournament:pre.roster.header")}
</h3>
{availability?.window ? (
<AvailabilityWindowText window={availability.window} />
) : null}
{canAddMembers ? (
<div className="stack md items-center">
<div className="text-center text-sm">
{t("tournament:actions.shareLink", { inviteLink })}
</div>
<div>
<SendouButton
size="small"
icon={copySuccess ? <Check /> : <Clipboard />}
onPress={() => copyToClipboard(inviteLink)}
variant="outlined"
>
{t("common:actions.copyToClipboard")}
</SendouButton>
</div>
</div>
<section className={clsx(styles.section, "stack sm")}>
{availability?.beyondHorizon ? (
<div className={styles.rosterMutedNote}>
{t("schedule:registration.beyondHorizon", {
date: dateFormatter.format(availability.beyondHorizon.opensAt),
})}
</div>
) : null}
<div className={styles.rosterGrid}>
{ownTeamMembers.map((member, i) => {
return (
<div
key={member.userId}
className="stack sm items-center text-sm"
data-testid={`member-num-${i + 1}`}
>
<Avatar size="xsm" user={member} />
{tournament.ctx.settings.requireInGameNames ? (
<div className={styles.rosterGridMemberName}>
<div className="text-center">
{member.inGameName ?? member.username}
</div>
{member.inGameName ? (
<div className="text-lighter text-xs font-bold text-center">
{member.username}
</div>
) : null}
</div>
) : (
<div className={styles.rosterGridMemberName}>
{member.username}
</div>
)}
</div>
);
})}
{new Array(missingMembers).fill(null).map((_, i) => {
return (
<div key={i} className={styles.missingPlayer}>
?
</div>
);
})}
{new Array(optionalMembers).fill(null).map((_, i) => {
return (
<div
key={i}
<ul className={styles.rosterRows}>
{ownTeamMembers.map((member, i) => (
<AvailabilityMemberRow
key={member.userId}
user={{
id: member.userId,
username: member.username,
discordId: member.discordId,
discordAvatar: member.discordAvatar,
customAvatarUrl: member.customAvatarUrl,
}}
entry={entryByUserId?.get(member.userId)}
showAvailability={Boolean(entryByUserId)}
primaryName={
requireInGameNames
? (member.inGameName ?? member.username)
: member.username
}
secondaryName={
requireInGameNames && member.inGameName
? member.username
: undefined
}
nameTestId={`member-num-${i + 1}`}
trailing={
canRemoveMembers && member.role !== "OWNER" ? (
<RemoveMemberButton member={member} />
) : null
}
/>
))}
{Array.from({ length: missingMembers }).map((_, i) => (
<li key={`required-${i}`} className={styles.emptySlotRow}>
<span className={styles.emptySlotCircle}>
<UserRound size={14} strokeWidth={3} />
</span>
{t("tournament:pre.roster.emptySlot")}
</li>
))}
{Array.from({ length: optionalMembers }).map((_, i) => (
<li
key={`optional-${i}`}
className={clsx(styles.emptySlotRow, styles.emptySlotRowOptional)}
>
<span
className={clsx(
styles.missingPlayer,
styles.missingPlayerOptional,
styles.emptySlotCircle,
styles.emptySlotCircleOptional,
)}
>
?
</div>
);
})}
</div>
{showDeleteMemberSection ? (
<DeleteMember members={ownTeamMembers} />
<UserRound size={14} strokeWidth={3} />
</span>
{t("tournament:pre.roster.emptySlot.optional")}
</li>
))}
</ul>
{entryByUserId ? (
<AvailabilitySummary
statuses={ownTeamMembers.map((member) =>
availabilityRowStatus(entryByUserId.get(member.userId)),
)}
/>
) : null}
{canAddMembers ? (
<div className={clsx(styles.addMembers, "stack md")}>
<h4 className={styles.addMembersHeading}>
{t("tournament:pre.roster.addMembers")}
</h4>
{quickAddPlayers.length > 0 ? (
<QuickAddPlayers
key={quickAddPlayers.map((player) => player.id).join(",")}
players={quickAddPlayers}
entryByUserId={entryByUserId}
/>
) : null}
<InviteLinkInput link={inviteLink} />
</div>
) : null}
</section>
{tournament.ctx.settings.requireInGameNames ? (
@@ -816,111 +860,119 @@ function FillRoster({
);
}
function DirectlyAddPlayerSelect({
function QuickAddPlayers({
players,
teams,
entryByUserId,
}: {
players: { id: number; username: string; teamId?: number }[];
teams: { id: number; name: string }[];
players: Array<{ id: number; username: string }>;
entryByUserId: Map<number, AvailabilityPanelEntry> | null;
}) {
const { t } = useTranslation(["tournament", "common"]);
const fetcher = useFetcher();
const id = React.useId();
const othersOptions = players
.filter((player) => !player.teamId)
.map((player) => {
return (
<option key={player.id} value={player.id}>
{player.username}
</option>
);
});
const sortedPlayers = entryByUserId
? R.sortBy(
players,
(player) =>
QUICK_ADD_STATUS_ORDER[
availabilityRowStatus(entryByUserId.get(player.id))
],
)
: players;
const [selectedUserId, setSelectedUserId] = React.useState<number | null>(
sortedPlayers[0]?.id ?? null,
);
// xxx: split team, pickup. quick button to add all player roles for team
return (
<fetcher.Form method="post" className="stack horizontal sm items-end">
<div>
<Label htmlFor={id}>
{t("tournament:pre.roster.addFriend.header")}
</Label>
<select id={id} name="userId">
{teams.map((team) => {
return (
<optgroup label={team.name} key={team.id}>
{players
.filter((player) => player.teamId === team.id)
.map((player) => {
return (
<option key={player.id} value={player.id}>
{player.username}
</option>
);
})}
</optgroup>
);
})}
{teams && teams.length > 0 ? (
<optgroup label={t("tournament:pre.roster.addFriend.others")}>
{othersOptions}
</optgroup>
) : (
othersOptions
<fetcher.Form method="post">
<div className={styles.quickAddRow}>
<SendouSelect
label={t("tournament:pre.roster.quickAdd")}
items={sortedPlayers}
selectedKey={selectedUserId}
onSelectionChange={(key) => setSelectedUserId(key as number | null)}
estimatedRowHeight={entryByUserId ? 52 : undefined}
className={styles.quickAddSelect}
data-testid="quick-add-select"
>
{(player) => (
<SendouSelectItem
id={player.id}
textValue={player.username}
data-testid={`availability-row-${player.id}`}
data-status={
entryByUserId
? availabilityRowStatus(entryByUserId.get(player.id))
: undefined
}
>
{entryByUserId ? (
<span className={styles.quickAddItem}>
<Text slot="label">{player.username}</Text>
<Text slot="description">
<span className={styles.quickAddItemAvailability}>
<AvailabilityStatusDots
statuses={[
availabilityRowStatus(entryByUserId.get(player.id)),
]}
/>
<AvailabilityRowDetail
entry={entryByUserId.get(player.id)}
/>
</span>
</Text>
</span>
) : (
player.username
)}
</SendouSelectItem>
)}
</select>
</SendouSelect>
{selectedUserId ? (
<input type="hidden" name="userId" value={selectedUserId} />
) : null}
<SubmitButton
schema={addPlayerSchema}
_action="ADD_PLAYER"
state={fetcher.state}
testId="add-player-button"
isDisabled={!selectedUserId}
>
{t("common:actions.add")}
</SubmitButton>
</div>
<SubmitButton
schema={addPlayerSchema}
_action="ADD_PLAYER"
state={fetcher.state}
testId="add-player-button"
>
{t("common:actions.add")}
</SubmitButton>
</fetcher.Form>
);
}
function DeleteMember({ members }: { members: TournamentTeamFull["members"] }) {
function RemoveMemberButton({
member,
}: {
member: TournamentTeamFull["members"][number];
}) {
const { t } = useTranslation(["tournament", "common"]);
const id = React.useId();
const fetcher = useFetcher();
const [expanded, setExpanded] = React.useState(false);
if (!expanded) {
return (
return (
<FormWithConfirm
dialogHeading={t("tournament:pre.roster.remove.confirm", {
name: member.username,
})}
submitButtonText={t("common:actions.remove")}
fields={[
["_action", "DELETE_TEAM_MEMBER"],
["userId", member.userId],
]}
>
<SendouButton
size="small"
variant="minimal-destructive"
onPress={() => setExpanded(true)}
>
{t("tournament:pre.roster.delete.button")}
</SendouButton>
);
}
return (
<fetcher.Form method="post">
<Label htmlFor={id}>{t("tournament:pre.roster.delete.header")}</Label>
<div className="stack md horizontal">
<select name="userId" id={id}>
{members
.filter((member) => member.role !== "OWNER")
.map((member) => (
<option key={member.userId} value={member.userId}>
{member.username}
</option>
))}
</select>
<SubmitButton
state={fetcher.state}
schema={deleteTeamMemberSchema}
_action="DELETE_TEAM_MEMBER"
variant="minimal-destructive"
>
{t("common:actions.delete")}
</SubmitButton>
</div>
</fetcher.Form>
icon={<X />}
aria-label={t("common:actions.remove")}
testId={`remove-member-${member.userId}`}
/>
</FormWithConfirm>
);
}
@@ -976,51 +1028,26 @@ function TeamCounterPickMapPoolPicker({
);
}
function TournamentRosterAvailability({
ownTeam,
}: {
ownTeam: TournamentTeamFull;
}) {
function SelectedTeamAvailability() {
const data = useLoaderData<TournamentRegisterPageLoader>();
const tournament = useTournament();
const user = useUser();
const { values } = useFormFieldContext();
const availability = data?.availability;
if (!availability) return null;
const roster = ownTeam.members.map((member) => ({
id: member.userId,
username: member.username,
discordId: member.discordId,
discordAvatar: member.discordAvatar,
customAvatarUrl: member.customAvatarUrl,
}));
return (
<RegistrationAvailabilityPanel
availability={availability}
roster={roster}
subCandidates={subCandidates({
data,
tournament,
rosterUserIds: roster.map((user) => user.id),
})}
/>
);
}
function SelectedTeamAvailability() {
const data = useLoaderData<TournamentRegisterPageLoader>();
const tournament = useTournament();
const { values } = useFormFieldContext();
const availability = data?.availability;
const teamId = values.teamId ? Number(values.teamId) : null;
if (!availability || !teamId) return null;
const roster = (data?.friendPlayers?.friends ?? [])
.filter((friend) => friend.teamId === teamId)
.map(panelUser);
if (roster.length === 0) return null;
// with a team selected the panel shows its full roster; signing up as a
// pickup it instead lists everyone the viewer could recruit (teammates and
// friends), which the panel keeps to those actually free during the event
const roster = teamId
? (data?.friendPlayers?.friends ?? [])
.filter((friend) => friend.teamId === teamId)
.map(panelUser)
: [];
if (teamId && roster.length === 0) return null;
return (
<RegistrationAvailabilityPanel
@@ -1029,7 +1056,11 @@ function SelectedTeamAvailability() {
subCandidates={subCandidates({
data,
tournament,
rosterUserIds: roster.map((user) => user.id),
rosterUserIds: teamId
? roster.map((rosterUser) => rosterUser.id)
: user
? [user.id]
: [],
})}
/>
);
@@ -1051,6 +1082,29 @@ function panelUser(user: {
};
}
function availabilityEntryByUserId(
data: ReturnType<typeof useLoaderData<TournamentRegisterPageLoader>>,
) {
const availability = data?.availability;
if (!availability || availability.beyondHorizon) return null;
return new Map(availability.entries.map((entry) => [entry.userId, entry]));
}
function teamMemberStatuses({
data,
teamId,
entryByUserId,
}: {
data: ReturnType<typeof useLoaderData<TournamentRegisterPageLoader>>;
teamId: number;
entryByUserId: Map<number, AvailabilityPanelEntry>;
}): Array<AvailabilityRowStatus> {
return (data?.friendPlayers?.friends ?? [])
.filter((friend) => friend.teamId === teamId)
.map((friend) => availabilityRowStatus(entryByUserId.get(friend.id)));
}
function subCandidates({
data,
tournament,

View File

@@ -289,6 +289,7 @@ export function FormField({
items={selectOptions.map((opt) => ({
value: opt.value,
label: opt.label,
description: opt.description,
}))}
value={value as string | null}
onChange={handleChange as (v: string | null) => void}

View File

@@ -1,3 +1,18 @@
.searchable {
--select-width: 100%;
}
.twoLineItem {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
min-width: 0;
}
.itemDescription {
font-size: var(--font-xs);
font-weight: var(--weight-body);
color: var(--color-text-high);
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -1,4 +1,5 @@
import * as React from "react";
import { Text } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { SendouSelect, SendouSelectItem } from "~/components/elements/Select";
import type { FormFieldItems, FormFieldProps } from "../types";
@@ -10,6 +11,8 @@ import {
} from "./FormFieldWrapper";
import styles from "./SelectFormField.module.css";
const TWO_LINE_ROW_HEIGHT = 52;
type SelectFormFieldProps<V extends string> = Omit<
FormFieldProps<"select">,
"items" | "clearable" | "onBlur" | "name" | "searchable"
@@ -56,12 +59,17 @@ export function SelectFormField<V extends string>({
return {
value: item.value,
resolvedLabel,
description: item.description,
};
});
if (searchable) {
const hasDescriptions = itemsWithResolvedLabels.some(
(item) => item.description,
);
if (searchable || hasDescriptions) {
return (
<SearchableSelect
<CustomSelect
name={name}
label={label}
bottomText={bottomText}
@@ -72,7 +80,7 @@ export function SelectFormField<V extends string>({
onBlur={onBlur}
clearable={clearable}
disabled={disabled}
searchPlaceholder={t("common:actions.search")}
searchPlaceholder={searchable ? t("common:actions.search") : undefined}
/>
);
}
@@ -113,7 +121,7 @@ export function SelectFormField<V extends string>({
);
}
function SearchableSelect<V extends string>({
function CustomSelect<V extends string>({
name,
label,
bottomText,
@@ -130,39 +138,68 @@ function SearchableSelect<V extends string>({
label?: string;
bottomText?: string;
error?: string;
items: Array<{ value: V; resolvedLabel: string }>;
items: Array<{
value: V;
resolvedLabel: string;
description?: React.ReactNode;
}>;
value: V | null;
onChange: (value: V | null) => void;
onBlur?: () => void;
clearable?: boolean;
disabled?: boolean;
searchPlaceholder: string;
searchPlaceholder?: string;
}) {
const { translatedLabel } = useTranslatedTexts({ label });
const selectItems = items.map((item) => ({
id: item.value,
textValue: item.resolvedLabel,
}));
const hasDescriptions = items.some((item) => item.description);
// the Autocomplete wrapper of searchable selects drops falsy keys, so only
// plain selects render the clear choice as a list item like the native
// select's "—" option; searchable ones keep the clear button
const hasEmptyItem = Boolean(clearable && !searchPlaceholder);
const selectItems = [
...(hasEmptyItem
? [{ id: "", textValue: "—", description: undefined }]
: []),
...items.map((item) => ({
id: item.value as string,
textValue: item.resolvedLabel,
description: item.description,
})),
];
return (
<div className={styles.searchable}>
<SendouSelect
label={translatedLabel}
selectedKey={value}
selectedKey={value ?? (hasEmptyItem ? "" : null)}
onSelectionChange={(key) => {
const newValue = key === "" ? null : (key as V);
onChange(newValue);
onBlur?.();
}}
items={selectItems}
search={{ placeholder: searchPlaceholder }}
clearable={clearable}
search={
searchPlaceholder ? { placeholder: searchPlaceholder } : undefined
}
clearable={clearable && !hasEmptyItem}
isDisabled={disabled}
estimatedRowHeight={hasDescriptions ? TWO_LINE_ROW_HEIGHT : undefined}
>
{(item) => (
<SendouSelectItem id={item.id} textValue={item.textValue}>
{item.textValue}
{item.description ? (
<span className={styles.twoLineItem}>
<Text slot="label">{item.textValue}</Text>
<Text slot="description" className={styles.itemDescription}>
{item.description}
</Text>
</span>
) : (
item.textValue
)}
</SendouSelectItem>
)}
</SendouSelect>

View File

@@ -1,3 +1,4 @@
import type * as React from "react";
import type * as v from "valibot";
import type { TeamSearchResult } from "~/components/elements/TeamSearch";
import type { TournamentSearchItem } from "~/components/elements/TournamentSearch";
@@ -59,6 +60,8 @@ interface FormFieldInGameName<T extends string> extends FormFieldBase<T> {
interface FormFieldItem<V extends string> {
label: string | number | ((lang: string) => string);
value: V;
/** Second line rendered under the label in the dropdown. Any item having one switches the field to the custom select. */
description?: React.ReactNode;
}
interface FormFieldItemWithImage<V extends string> extends FormFieldItem<V> {
@@ -251,6 +254,8 @@ export type TrophyOption = {
export type SelectOption = {
value: string;
label: string;
/** Second line rendered under the label in the dropdown. Any option having one switches the field to the custom select. */
description?: React.ReactNode;
};
/** Brand type to encode required options directly in schema types */

View File

@@ -47,6 +47,15 @@ export class TournamentRegisterPage {
return this.page.getByTestId(`member-num-${number}`);
}
availabilityRow(userId: number) {
return this.page.getByTestId(`availability-row-${userId}`);
}
/** Opens the quick add dropdown so its player rows render. */
async openQuickAdd() {
await this.page.getByTestId("quick-add-select").getByRole("button").click();
}
stepCheckmark(number: number) {
return this.page.getByTestId(`checkmark-icon-num-${number}`);
}

View File

@@ -81,13 +81,13 @@ test.describe("Tournament", () => {
page,
factories,
}) => {
const [partialMember, unknownMember, stranger, friend] =
await factories.UserFactory.createMany(4);
const [captain, partialMember, unknownMember, stranger, friend] =
await factories.UserFactory.createMany(5);
await factories.TeamFactory.create({
memberUserIds: [ADMIN_ID, partialMember.id, unknownMember.id],
memberUserIds: [captain.id, partialMember.id, unknownMember.id],
});
await factories.FriendshipFactory.create({
userOneId: ADMIN_ID,
userOneId: captain.id,
userTwoId: friend.id,
});
@@ -99,7 +99,7 @@ test.describe("Tournament", () => {
await factories.TournamentTeamFactory.create({
tournamentId: tournament.id,
memberUserIds: [
ADMIN_ID,
captain.id,
partialMember.id,
unknownMember.id,
stranger.id,
@@ -114,7 +114,7 @@ test.describe("Tournament", () => {
startsAt: dateToDatabaseTimestamp(startsAt),
endsAt: dateToDatabaseTimestamp(addHours(startsAt, 5)),
};
for (const userId of [ADMIN_ID, friend.id]) {
for (const userId of [captain.id, friend.id]) {
await factories.AvailabilityWeekFactory.create({
userId,
weekStartsAt,
@@ -134,14 +134,13 @@ test.describe("Tournament", () => {
],
});
await impersonate(page);
await impersonate(page, captain.id);
await setTimezoneCookie(page);
const register = new TournamentRegisterPage(page);
await register.goto(tournament.id);
const row = (userId: number) =>
page.getByTestId(`availability-row-${userId}`);
await expect(row(ADMIN_ID)).toHaveAttribute("data-status", "available");
const row = (userId: number) => register.availabilityRow(userId);
await expect(row(captain.id)).toHaveAttribute("data-status", "available");
await expect(row(partialMember.id)).toHaveAttribute(
"data-status",
"partial",
@@ -153,7 +152,8 @@ test.describe("Tournament", () => {
// on the tournament roster without being a teammate or a friend, so
// their schedule is not the viewer's to see
await expect(row(stranger.id)).toHaveAttribute("data-status", "hidden");
// the friend with an overlapping submitted range lands in the sub row
// the friend with an overlapping submitted range is offered in quick add
await register.openQuickAdd();
await expect(row(friend.id)).toHaveAttribute("data-status", "available");
});

View File

@@ -360,6 +360,7 @@
"xsearch.link.noScreenshots": "",
"build.private": "Privat",
"or": "Eller",
"inviteLink": "",
"yes": "Ja",
"no": "Nej",
"leaderboard.tabs.players": "",

View File

@@ -57,11 +57,12 @@
"pre.roster.header": "Udfyld holdmedlemslisten",
"pre.roster.footer": "Mindst {{atLeastCount}} holdmedlemmer kræves for at deltage. Der kan maks være {{maxCount}} på holdet",
"pre.roster.footer.noSubs": "",
"pre.roster.addFriend.header": "",
"pre.roster.addFriend.others": "",
"pre.roster.delete.button": "Fjern medlem",
"pre.roster.delete.header": "Medlem der fjernes",
"pre.roster.ignWarning": "",
"pre.roster.quickAdd": "",
"pre.roster.addMembers": "",
"pre.roster.emptySlot": "",
"pre.roster.emptySlot.optional": "",
"pre.roster.remove.confirm": "",
"pre.pool.header": "Vælg banepulje",
"pre.pool.banned": "Bandlyst",
"pre.pool.tiebreaker.short": "Tiebreaker",
@@ -153,7 +154,6 @@
"staff.divider.addedForEvent": "",
"staff.editOrganization": "",
"actions.addSub": "tilføj Suppleant",
"actions.shareLink": "Del invitationslinket for at tilføje medlemmer: {{inviteLink}}",
"actions.sub.prompt_one": "Du kan stadigvæk tilføje {{count}} Suppleant til din holdliste",
"actions.sub.prompt_other": "Du kan stadigvæk tilføje {{count}} Suppleanter til din holdliste",
"actions.sub.prompt_zero": "Din holdliste er fuld, så du kan ikke tilføje flere Suppleanter",

View File

@@ -360,6 +360,7 @@
"xsearch.link.noScreenshots": "",
"build.private": "",
"or": "",
"inviteLink": "",
"yes": "",
"no": "",
"leaderboard.tabs.players": "",

View File

@@ -57,11 +57,12 @@
"pre.roster.header": "Roster füllen",
"pre.roster.footer": "Mindestens {{atLeastCount}} Teammitglieder sind zum Spielen erforderlich. Maximale Rostergröße ist {{maxCount}}",
"pre.roster.footer.noSubs": "",
"pre.roster.addFriend.header": "",
"pre.roster.addFriend.others": "",
"pre.roster.delete.button": "Mitglied löschen",
"pre.roster.delete.header": "Zu entfernendes Mitglied",
"pre.roster.ignWarning": "",
"pre.roster.quickAdd": "",
"pre.roster.addMembers": "",
"pre.roster.emptySlot": "",
"pre.roster.emptySlot.optional": "",
"pre.roster.remove.confirm": "",
"pre.pool.header": "Arenenpool wählen",
"pre.pool.banned": "Gebannt",
"pre.pool.tiebreaker.short": "Tiebreaker",
@@ -153,7 +154,6 @@
"staff.divider.addedForEvent": "",
"staff.editOrganization": "",
"actions.addSub": "Ersatzspieler hinzufügen",
"actions.shareLink": "Teile deinen Invite-Link, um Mitglieder hinzuzufügen: {{inviteLink}}",
"actions.sub.prompt_one": "Du kannst noch {{count}} Ersatzspieler zu deinem Roster hinzufügen",
"actions.sub.prompt_other": "Du kannst noch {{count}} Ersatzspieler zu deinem Roster hinzufügen",
"actions.sub.prompt_zero": "Dein Roster ist voll und keine weiteren Ersatzspieler können hinzugefügt werden",

View File

@@ -360,6 +360,7 @@
"xsearch.link.noScreenshots": "Without a link to the player page the request can not be considered. Screenshots are not necessary unless asked for.",
"build.private": "Private",
"or": "Or",
"inviteLink": "Invite link",
"yes": "Yes",
"no": "No",
"leaderboard.tabs.players": "Players",

View File

@@ -57,11 +57,12 @@
"pre.roster.header": "Fill roster",
"pre.roster.footer": "At least {{atLeastCount}} members are required to participate. Max roster size is {{maxCount}}.",
"pre.roster.footer.noSubs": "Format is {{format}}. No subs allowed.",
"pre.roster.addFriend.header": "Add friends",
"pre.roster.addFriend.others": "Others",
"pre.roster.delete.button": "Delete member",
"pre.roster.delete.header": "Member to delete",
"pre.roster.ignWarning": "Note that you are expected to use the in-game names as listed above. Playing in the event with a different name or using the alias feature might result in disqualification.",
"pre.roster.quickAdd": "Quick add",
"pre.roster.addMembers": "Add members",
"pre.roster.emptySlot": "Empty slot",
"pre.roster.emptySlot.optional": "Optional slot",
"pre.roster.remove.confirm": "Remove {{name}} from the roster?",
"pre.pool.header": "Pick map pool",
"pre.pool.banned": "Banned",
"pre.pool.tiebreaker.short": "Tiebreaker",
@@ -153,7 +154,6 @@
"staff.divider.addedForEvent": "For this event",
"staff.editOrganization": "Edit organization",
"actions.addSub": "Add sub",
"actions.shareLink": "Share your invite link to add members: {{inviteLink}}",
"actions.sub.prompt_other": "You can still add {{count}} subs to your roster",
"actions.sub.prompt_one": "You can still add {{count}} sub to your roster",
"actions.sub.prompt_zero": "Your roster is full and more subs can't be added",

View File

@@ -360,6 +360,7 @@
"xsearch.link.noScreenshots": "No se puede tener en cuenta la solicitud sin un enlace a la página de jugador. No hace falta adjuntar capturas a menos que se pidan.",
"build.private": "Privado",
"or": "O",
"inviteLink": "",
"yes": "Sí",
"no": "No",
"leaderboard.tabs.players": "",

View File

@@ -57,11 +57,12 @@
"pre.roster.header": "Llenar equipo",
"pre.roster.footer": "Se requieren al menos {{atLeastCount}} miembros para participar. La cantidad máxima es {{maxCount}}",
"pre.roster.footer.noSubs": "El formato es {{format}}. No se permiten subs.",
"pre.roster.addFriend.header": "Añadir amigos",
"pre.roster.addFriend.others": "",
"pre.roster.delete.button": "Borrar miembro",
"pre.roster.delete.header": "Miembro que quieres borrar",
"pre.roster.ignWarning": "",
"pre.roster.quickAdd": "",
"pre.roster.addMembers": "",
"pre.roster.emptySlot": "",
"pre.roster.emptySlot.optional": "",
"pre.roster.remove.confirm": "",
"pre.pool.header": "Escoger grupo de mapas",
"pre.pool.banned": "Prohibidos",
"pre.pool.tiebreaker.short": "Desempate",
@@ -154,7 +155,6 @@
"staff.divider.addedForEvent": "Para este evento",
"staff.editOrganization": "Editar organización",
"actions.addSub": "Añadir sub",
"actions.shareLink": "Comparte enlace de invitación para añadir miembros: {{inviteLink}}",
"actions.sub.prompt_one": "Aún puedes añadir {{count}} sub a tu equipo",
"actions.sub.prompt_many": "",
"actions.sub.prompt_other": "Aún puedes añadir {{count}} subs a tu equipo",

View File

@@ -360,6 +360,7 @@
"xsearch.link.noScreenshots": "No se puede tener en cuenta la solicitud sin un enlace a la página de jugador. No hace falta adjuntar capturas a menos que se pidan.",
"build.private": "Privado",
"or": "O",
"inviteLink": "",
"yes": "Sí",
"no": "No",
"leaderboard.tabs.players": "",

View File

@@ -57,11 +57,12 @@
"pre.roster.header": "Llenar equipo",
"pre.roster.footer": "Se requieren al menos {{atLeastCount}} miembros para participar. La cantidad máxima es {{maxCount}}",
"pre.roster.footer.noSubs": "El formato es {{format}}. No se permiten subs.",
"pre.roster.addFriend.header": "Añadir amigos",
"pre.roster.addFriend.others": "",
"pre.roster.delete.button": "Borrar miembro",
"pre.roster.delete.header": "Miembro que quieres borrar",
"pre.roster.ignWarning": "",
"pre.roster.quickAdd": "",
"pre.roster.addMembers": "",
"pre.roster.emptySlot": "",
"pre.roster.emptySlot.optional": "",
"pre.roster.remove.confirm": "",
"pre.pool.header": "Escoger grupo de escenarios",
"pre.pool.banned": "Prohibidos",
"pre.pool.tiebreaker.short": "Desempate",
@@ -154,7 +155,6 @@
"staff.divider.addedForEvent": "Para este evento",
"staff.editOrganization": "Editar organización",
"actions.addSub": "Añadir sub",
"actions.shareLink": "Comparte enlace de invitación para añadir miembros: {{inviteLink}}",
"actions.sub.prompt_one": "Aún puedes añadir {{count}} sub a tu equipo",
"actions.sub.prompt_many": "",
"actions.sub.prompt_other": "Aún puedes añadir {{count}} subs a tu equipo",

View File

@@ -360,6 +360,7 @@
"xsearch.link.noScreenshots": "",
"build.private": "Privé",
"or": "Ou",
"inviteLink": "",
"yes": "Oui",
"no": "Non",
"leaderboard.tabs.players": "",

View File

@@ -57,11 +57,12 @@
"pre.roster.header": "Remplir la liste",
"pre.roster.footer": "Au moins {{atLeastCount}} membres sont requis pour participer. La taille maximum est de {{maxCount}}",
"pre.roster.footer.noSubs": "",
"pre.roster.addFriend.header": "",
"pre.roster.addFriend.others": "",
"pre.roster.delete.button": "Effacer membre",
"pre.roster.delete.header": "Membre à effacer",
"pre.roster.ignWarning": "",
"pre.roster.quickAdd": "",
"pre.roster.addMembers": "",
"pre.roster.emptySlot": "",
"pre.roster.emptySlot.optional": "",
"pre.roster.remove.confirm": "",
"pre.pool.header": "Sélection de stage",
"pre.pool.banned": "",
"pre.pool.tiebreaker.short": "",
@@ -154,7 +155,6 @@
"staff.divider.addedForEvent": "",
"staff.editOrganization": "",
"actions.addSub": "Ajouter remplaçant",
"actions.shareLink": "Partagez votre lien d'invitation pour ajouter des membres: {{inviteLink}}",
"actions.sub.prompt_one": "Vous pouvez encore ajouter {{count}} remplaçant à votre liste",
"actions.sub.prompt_many": "",
"actions.sub.prompt_other": "Vous pouvez encore ajouter {{count}} remplaçants à votre liste",

View File

@@ -360,6 +360,7 @@
"xsearch.link.noScreenshots": "",
"build.private": "Privé",
"or": "Ou",
"inviteLink": "",
"yes": "Oui",
"no": "Non",
"leaderboard.tabs.players": "",

View File

@@ -57,11 +57,12 @@
"pre.roster.header": "Remplir la liste",
"pre.roster.footer": "Au moins {{atLeastCount}} membres sont requis pour participer. La taille maximum est de {{maxCount}}",
"pre.roster.footer.noSubs": "",
"pre.roster.addFriend.header": "",
"pre.roster.addFriend.others": "",
"pre.roster.delete.button": "Effacer membre",
"pre.roster.delete.header": "Membre à effacer",
"pre.roster.ignWarning": "",
"pre.roster.quickAdd": "",
"pre.roster.addMembers": "",
"pre.roster.emptySlot": "",
"pre.roster.emptySlot.optional": "",
"pre.roster.remove.confirm": "",
"pre.pool.header": "Sélection de stage",
"pre.pool.banned": "Bannis",
"pre.pool.tiebreaker.short": "Manche décisive",
@@ -154,7 +155,6 @@
"staff.divider.addedForEvent": "",
"staff.editOrganization": "",
"actions.addSub": "Ajouter remplaçant",
"actions.shareLink": "Partagez votre lien d'invitation pour ajouter des membres: {{inviteLink}}",
"actions.sub.prompt_one": "Vous pouvez encore ajouter {{count}} remplaçant à votre liste",
"actions.sub.prompt_many": "",
"actions.sub.prompt_other": "Vous pouvez encore ajouter {{count}} remplaçants à votre liste",

View File

@@ -360,6 +360,7 @@
"xsearch.link.noScreenshots": "",
"build.private": "פרטי",
"or": "או",
"inviteLink": "",
"yes": "כן",
"no": "לא",
"leaderboard.tabs.players": "",

View File

@@ -57,11 +57,12 @@
"pre.roster.header": "מלא צוות",
"pre.roster.footer": "לפחות {{atLeastCount}} חברי צוות נדרשים כדי להשתתף. גודל הצוות המרבי הוא {{maxCount}}",
"pre.roster.footer.noSubs": "",
"pre.roster.addFriend.header": "",
"pre.roster.addFriend.others": "",
"pre.roster.delete.button": "מחקו חבר צוות",
"pre.roster.delete.header": "חבר צוות למחיקה",
"pre.roster.ignWarning": "",
"pre.roster.quickAdd": "",
"pre.roster.addMembers": "",
"pre.roster.emptySlot": "",
"pre.roster.emptySlot.optional": "",
"pre.roster.remove.confirm": "",
"pre.pool.header": "בחרו מאגר מפות",
"pre.pool.banned": "",
"pre.pool.tiebreaker.short": "",
@@ -154,7 +155,6 @@
"staff.divider.addedForEvent": "",
"staff.editOrganization": "",
"actions.addSub": "הוסיפו ממלא מקום",
"actions.shareLink": "שתפו קישור הזמנה להוספת חברי צוות: {{inviteLink}}",
"actions.sub.prompt_one": "אתם עדיין יכולים להוסיף {{count}} ממלא מקום לצוות שלכם",
"actions.sub.prompt_two": "",
"actions.sub.prompt_other": "אתם עדיין יכולים להוסיף {{count}} ממלאי מקום לצוות שלכם",

View File

@@ -360,6 +360,7 @@
"xsearch.link.noScreenshots": "",
"build.private": "Privato",
"or": "O",
"inviteLink": "",
"yes": "Sì",
"no": "No",
"leaderboard.tabs.players": "",

View File

@@ -57,11 +57,12 @@
"pre.roster.header": "Riempi roster",
"pre.roster.footer": "Sono necessari almeno {{atLeastCount}} membri per partecipare. La dimensione massima del roster è {{maxCount}}",
"pre.roster.footer.noSubs": "",
"pre.roster.addFriend.header": "",
"pre.roster.addFriend.others": "",
"pre.roster.delete.button": "Elimina membro",
"pre.roster.delete.header": "Membro da eliminare",
"pre.roster.ignWarning": "",
"pre.roster.quickAdd": "",
"pre.roster.addMembers": "",
"pre.roster.emptySlot": "",
"pre.roster.emptySlot.optional": "",
"pre.roster.remove.confirm": "",
"pre.pool.header": "Scegli pool mappe",
"pre.pool.banned": "Banneta",
"pre.pool.tiebreaker.short": "Spareggio",
@@ -154,7 +155,6 @@
"staff.divider.addedForEvent": "",
"staff.editOrganization": "",
"actions.addSub": "Aggiungi sub",
"actions.shareLink": "Condividi il tuo link d'invito per aggiungere membri: {{inviteLink}}",
"actions.sub.prompt_one": "Puoi ancora aggiungere {{count}} sub al tuo roster",
"actions.sub.prompt_many": "",
"actions.sub.prompt_other": "Puoi ancora aggiungere {{count}} sub al tuo roster",

View File

@@ -360,6 +360,7 @@
"xsearch.link.noScreenshots": "",
"build.private": "非公開",
"or": "または",
"inviteLink": "",
"yes": "はい",
"no": "いいえ",
"leaderboard.tabs.players": "",

View File

@@ -57,11 +57,12 @@
"pre.roster.header": "参加プレイヤーを登録",
"pre.roster.footer": "少なくとも {{atLeastCount}} 人の参加が必要です。最大メンバー数は {{maxCount}} です。",
"pre.roster.footer.noSubs": "",
"pre.roster.addFriend.header": "",
"pre.roster.addFriend.others": "",
"pre.roster.delete.button": "メンバーを削除する",
"pre.roster.delete.header": "削除するメンバー",
"pre.roster.ignWarning": "",
"pre.roster.quickAdd": "",
"pre.roster.addMembers": "",
"pre.roster.emptySlot": "",
"pre.roster.emptySlot.optional": "",
"pre.roster.remove.confirm": "",
"pre.pool.header": "マッププールを選択する",
"pre.pool.banned": "禁止",
"pre.pool.tiebreaker.short": "タイブレイカー",
@@ -151,7 +152,6 @@
"staff.divider.addedForEvent": "",
"staff.editOrganization": "",
"actions.addSub": "サブを追加",
"actions.shareLink": "メンバー招待リンクをシェアする: {{inviteLink}}",
"actions.sub.prompt_zero": "メンバーが上限に達しているので、これ以上サブを追加することができません",
"actions.finalize": "",
"actions.finalize.button": "",

View File

@@ -360,6 +360,7 @@
"xsearch.link.noScreenshots": "",
"build.private": "Private",
"or": "또는",
"inviteLink": "",
"yes": "네",
"no": "아니오",
"leaderboard.tabs.players": "",

View File

@@ -57,11 +57,12 @@
"pre.roster.header": "",
"pre.roster.footer": "",
"pre.roster.footer.noSubs": "",
"pre.roster.addFriend.header": "",
"pre.roster.addFriend.others": "",
"pre.roster.delete.button": "",
"pre.roster.delete.header": "",
"pre.roster.ignWarning": "",
"pre.roster.quickAdd": "",
"pre.roster.addMembers": "",
"pre.roster.emptySlot": "",
"pre.roster.emptySlot.optional": "",
"pre.roster.remove.confirm": "",
"pre.pool.header": "",
"pre.pool.banned": "",
"pre.pool.tiebreaker.short": "",
@@ -151,7 +152,6 @@
"staff.divider.addedForEvent": "",
"staff.editOrganization": "",
"actions.addSub": "",
"actions.shareLink": "",
"actions.sub.prompt_zero": "",
"actions.finalize": "",
"actions.finalize.button": "",

View File

@@ -360,6 +360,7 @@
"xsearch.link.noScreenshots": "",
"build.private": "",
"or": "",
"inviteLink": "",
"yes": "",
"no": "",
"leaderboard.tabs.players": "",

View File

@@ -57,11 +57,12 @@
"pre.roster.header": "",
"pre.roster.footer": "",
"pre.roster.footer.noSubs": "",
"pre.roster.addFriend.header": "",
"pre.roster.addFriend.others": "",
"pre.roster.delete.button": "",
"pre.roster.delete.header": "",
"pre.roster.ignWarning": "",
"pre.roster.quickAdd": "",
"pre.roster.addMembers": "",
"pre.roster.emptySlot": "",
"pre.roster.emptySlot.optional": "",
"pre.roster.remove.confirm": "",
"pre.pool.header": "",
"pre.pool.banned": "",
"pre.pool.tiebreaker.short": "",
@@ -153,7 +154,6 @@
"staff.divider.addedForEvent": "",
"staff.editOrganization": "",
"actions.addSub": "",
"actions.shareLink": "",
"actions.sub.prompt_one": "",
"actions.sub.prompt_other": "",
"actions.sub.prompt_zero": "",

View File

@@ -360,6 +360,7 @@
"xsearch.link.noScreenshots": "",
"build.private": "",
"or": "",
"inviteLink": "",
"yes": "",
"no": "",
"leaderboard.tabs.players": "",

View File

@@ -57,11 +57,12 @@
"pre.roster.header": "",
"pre.roster.footer": "",
"pre.roster.footer.noSubs": "",
"pre.roster.addFriend.header": "",
"pre.roster.addFriend.others": "",
"pre.roster.delete.button": "",
"pre.roster.delete.header": "",
"pre.roster.ignWarning": "",
"pre.roster.quickAdd": "",
"pre.roster.addMembers": "",
"pre.roster.emptySlot": "",
"pre.roster.emptySlot.optional": "",
"pre.roster.remove.confirm": "",
"pre.pool.header": "",
"pre.pool.banned": "",
"pre.pool.tiebreaker.short": "",
@@ -155,7 +156,6 @@
"staff.divider.addedForEvent": "",
"staff.editOrganization": "",
"actions.addSub": "",
"actions.shareLink": "",
"actions.sub.prompt_one": "",
"actions.sub.prompt_few": "",
"actions.sub.prompt_many": "",

View File

@@ -360,6 +360,7 @@
"xsearch.link.noScreenshots": "",
"build.private": "Privada",
"or": "Ou",
"inviteLink": "",
"yes": "Sim",
"no": "Não",
"leaderboard.tabs.players": "",

View File

@@ -57,11 +57,12 @@
"pre.roster.header": "Preencher lista",
"pre.roster.footer": "Pelo menos {{atLeastCount}} membros são necessários para participar. O número máximo da lista de participantes é de {{maxCount}}",
"pre.roster.footer.noSubs": "",
"pre.roster.addFriend.header": "",
"pre.roster.addFriend.others": "",
"pre.roster.delete.button": "Excluir membro",
"pre.roster.delete.header": "Membro a ser excluído",
"pre.roster.ignWarning": "",
"pre.roster.quickAdd": "",
"pre.roster.addMembers": "",
"pre.roster.emptySlot": "",
"pre.roster.emptySlot.optional": "",
"pre.roster.remove.confirm": "",
"pre.pool.header": "Escolher seleção de mapas",
"pre.pool.banned": "Banido",
"pre.pool.tiebreaker.short": "Desempate",
@@ -154,7 +155,6 @@
"staff.divider.addedForEvent": "",
"staff.editOrganization": "",
"actions.addSub": "Adicionar substituto(a)",
"actions.shareLink": "Compartilhe seu link de convite para adicionar membros: {{inviteLink}}",
"actions.sub.prompt_one": "Você ainda pode adicionar {{count}} substituto(a) à sua lista",
"actions.sub.prompt_many": "",
"actions.sub.prompt_other": "Você ainda pode adicionar {{count}} substitutos(as) à sua lista",

View File

@@ -360,6 +360,7 @@
"xsearch.link.noScreenshots": "",
"build.private": "Приватный",
"or": "Или",
"inviteLink": "",
"yes": "Да",
"no": "Нет",
"leaderboard.tabs.players": "",

View File

@@ -57,11 +57,12 @@
"pre.roster.header": "Заполните состав",
"pre.roster.footer": "Необходимый минимум игроков для данного турнира: {{atLeastCount}}. Максимальное количество игроков в составе: {{maxCount}}",
"pre.roster.footer.noSubs": "",
"pre.roster.addFriend.header": "",
"pre.roster.addFriend.others": "",
"pre.roster.delete.button": "Удалить участника",
"pre.roster.delete.header": "Участник для удаления",
"pre.roster.ignWarning": "",
"pre.roster.quickAdd": "",
"pre.roster.addMembers": "",
"pre.roster.emptySlot": "",
"pre.roster.emptySlot.optional": "",
"pre.roster.remove.confirm": "",
"pre.pool.header": "Выберите пул арен",
"pre.pool.banned": "Запрещено",
"pre.pool.tiebreaker.short": "Тайбрейк",
@@ -155,7 +156,6 @@
"staff.divider.addedForEvent": "",
"staff.editOrganization": "",
"actions.addSub": "Добавить запасного",
"actions.shareLink": "Ссылка приглашения в команду: {{inviteLink}}",
"actions.sub.prompt_one": "Вы ещё можете добавить {{count}} запасного",
"actions.sub.prompt_few": "",
"actions.sub.prompt_many": "",

View File

@@ -360,6 +360,7 @@
"xsearch.link.noScreenshots": "",
"build.private": "私人",
"or": "或",
"inviteLink": "",
"yes": "是",
"no": "否",
"leaderboard.tabs.players": "",

View File

@@ -57,11 +57,12 @@
"pre.roster.header": "填写阵容",
"pre.roster.footer": "至少需要 {{atLeastCount}} 名成员才能参赛。最大阵容人数为 {{maxCount}} 人。",
"pre.roster.footer.noSubs": "赛制为 {{format}}。不允许替补。",
"pre.roster.addFriend.header": "添加好友",
"pre.roster.addFriend.others": "",
"pre.roster.delete.button": "删除成员",
"pre.roster.delete.header": "要删除的成员",
"pre.roster.ignWarning": "",
"pre.roster.quickAdd": "",
"pre.roster.addMembers": "",
"pre.roster.emptySlot": "",
"pre.roster.emptySlot.optional": "",
"pre.roster.remove.confirm": "",
"pre.pool.header": "选择场地池",
"pre.pool.banned": "已禁用",
"pre.pool.tiebreaker.short": "决胜局场地",
@@ -152,7 +153,6 @@
"staff.divider.addedForEvent": "仅限此赛事",
"staff.editOrganization": "编辑组织",
"actions.addSub": "添加替补",
"actions.shareLink": "分享您的邀请链接以添加成员: {{inviteLink}}",
"actions.sub.prompt": "您仍可以向阵容中添加 {{count}} 名替补",
"actions.sub.prompt_zero": "您的阵容已满,无法添加更多替补",
"actions.finalize": "正在结束赛事",