Move app to apps/web-react in pnpm workspace layout

This commit is contained in:
Kalle
2026-08-16 09:20:33 +03:00
parent 7e365ccfcf
commit bbc8ea57af
2913 changed files with 227 additions and 250 deletions

View File

@@ -0,0 +1,67 @@
import { describe, expect, test } from "vitest";
import * as TeamFactory from "~/db/seed/factories/TeamFactory";
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import { db } from "~/db/sql";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import * as TeamRepository from "./TeamRepository.server";
describe("findResultsById", () => {
test("participant who was on the roster at the result's time but left later is not a sub", async () => {
const [owner, formerMember] = await UserFactory.createMany(2);
const team = await TeamFactory.create({
memberUserIds: [owner.id, formerMember.id],
});
const tournament = await TournamentFactory.create({ authorId: owner.id });
const tournamentTeam = await TournamentTeamFactory.create({
tournamentId: tournament.id,
memberUserIds: [owner.id, formerMember.id],
team: {
name: team.name,
prefersNotToHost: 0,
teamId: team.id,
},
});
await TournamentRepository.finalize({
tournamentId: tournament.id,
season: undefined,
summary: {
skills: [],
seedingSkills: [],
mapResultDeltas: [],
playerResultDeltas: [],
tournamentResults: [owner.id, formerMember.id].map((userId) => ({
userId,
placement: 1,
participantCount: 8,
tournamentTeamId: tournamentTeam.id,
div: null,
})),
spDiffs: null,
setResults: new Map(),
},
});
const { startsAt } = await db
.selectFrom("CalendarEventDate")
.select("startsAt")
.where("eventId", "=", tournament.eventId)
.executeTakeFirstOrThrow();
// biome-ignore lint/plugin: a membership spanning a past event start only arises with the passage of time; `backdate` can't address AllTeamMember as it has no id column
await db
.updateTable("AllTeamMember")
.set({ createdAt: startsAt - 1000, leftAt: startsAt + 1000 })
.where("teamId", "=", team.id)
.where("userId", "=", formerMember.id)
.execute();
const results = await TeamRepository.findResultsById(team.id);
expect(results).toHaveLength(1);
expect(results[0].subs).toHaveLength(0);
});
});

View File

@@ -0,0 +1,742 @@
import { type Insertable, sql, type Transaction } from "kysely";
import { db } from "~/db/sql";
import type { DB, Tables } from "~/db/tables";
import type { CustomTheme, UserMapModePreferences } from "~/db/tables-json";
import { actorId } from "~/features/auth/core/user.server";
import * as LFGRepository from "~/features/lfg/LFGRepository.server";
import * as MatchProfileRepository from "~/features/match-profile/MatchProfileRepository.server";
import { NON_PLAYER_TEAM_ROLES } from "~/features/team/team-constants";
import { subsOfResult } from "~/features/team/team-utils";
import { databaseTimestampNow } from "~/utils/dates";
import { shortNanoid } from "~/utils/id";
import invariant from "~/utils/invariant";
import {
commonUserSelect,
concatUserSubmittedImagePrefix,
jsonArrayFrom,
tournamentLogoOrNull,
userProfileWeapons,
} from "~/utils/kysely.server";
import { toDBBoolean } from "~/utils/sql";
import { mySlugify } from "~/utils/urls";
export function findAllUndisbanded() {
return db
.selectFrom("Team")
.leftJoin("UserSubmittedImage", "UserSubmittedImage.id", "Team.avatarImgId")
.select(({ eb }) => [
"Team.customUrl",
"Team.name",
"Team.tag",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"avatarUrl",
),
jsonArrayFrom(
eb
// AllTeamMember directly instead of the TeamMemberWithSecondary view:
// the view's team-existence check is redundant when joining to Team
.selectFrom("AllTeamMember")
.innerJoin("User", "User.id", "AllTeamMember.userId")
.leftJoin("PlusTier", "PlusTier.userId", "User.id")
.select(["User.id", "User.username", "PlusTier.tier as plusTier"])
.whereRef("AllTeamMember.teamId", "=", "Team.id")
.where("AllTeamMember.leftAt", "is", null)
.orderBy("AllTeamMember.order", "asc"),
).as("members"),
])
.execute();
}
export function searchByName({
query,
limit,
}: {
query: string;
limit: number;
}) {
return db
.selectFrom("Team")
.leftJoin("UserSubmittedImage", "UserSubmittedImage.id", "Team.avatarImgId")
.select(({ eb }) => [
"Team.id",
"Team.customUrl",
"Team.name",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"avatarUrl",
),
jsonArrayFrom(
eb
.selectFrom("TeamMemberWithSecondary")
.innerJoin("User", "User.id", "TeamMemberWithSecondary.userId")
.select(["User.id", "User.username", "User.tournamentName"])
.whereRef("TeamMemberWithSecondary.teamId", "=", "Team.id")
.where((eb2) =>
eb2.and([
eb2.or([
eb2("TeamMemberWithSecondary.role", "is", null),
eb2(
"TeamMemberWithSecondary.role",
"not in",
NON_PLAYER_TEAM_ROLES,
),
]),
eb2.or([
eb2("TeamMemberWithSecondary.roleType", "is", null),
eb2("TeamMemberWithSecondary.roleType", "!=", "OTHER"),
]),
]),
)
.orderBy("TeamMemberWithSecondary.order", "asc"),
).as("members"),
])
.where("Team.name", "like", `%${query}%`)
.orderBy("Team.name", "asc")
.limit(limit)
.execute();
}
export function findById(teamId: number) {
return db
.selectFrom("AllTeam")
.select(["AllTeam.id", "AllTeam.name"])
.where("AllTeam.id", "=", teamId)
.executeTakeFirst();
}
export function findAllMemberOfByUserId(userId: number) {
return db
.selectFrom("TeamMemberWithSecondary")
.innerJoin("Team", "Team.id", "TeamMemberWithSecondary.teamId")
.leftJoin("UserSubmittedImage", "UserSubmittedImage.id", "Team.avatarImgId")
.select(({ eb }) => [
"Team.id",
"Team.customUrl",
"Team.name",
"Team.mapModePreferences",
"TeamMemberWithSecondary.role",
"TeamMemberWithSecondary.customRole",
"TeamMemberWithSecondary.isOwner",
"TeamMemberWithSecondary.isManager",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"logoUrl",
),
])
.where("TeamMemberWithSecondary.userId", "=", userId)
.orderBy("TeamMemberWithSecondary.isMainTeam", "desc")
.orderBy("Team.name", "asc")
.execute();
}
export type findByCustomUrl = NonNullable<
Awaited<ReturnType<typeof findByCustomUrl>>
>;
export async function findByCustomUrl(
customUrl: string,
{
includeInviteCode = false,
includeUnvalidatedImages = false,
includeMapModePreferences = false,
} = {},
) {
// join the unvalidated table (instead of the validated-only `UserSubmittedImage` view) so the
// edit page can preview images still pending moderation; for everyone else the url is gated on
// `validatedAt` so pending images stay hidden
const row = await db
.selectFrom("Team")
.leftJoin(
"UnvalidatedUserSubmittedImage as AvatarImage",
"AvatarImage.id",
"Team.avatarImgId",
)
.leftJoin(
"UnvalidatedUserSubmittedImage as BannerImage",
"BannerImage.id",
"Team.bannerImgId",
)
.select(({ eb }) => [
"Team.id",
"Team.name",
"Team.bsky",
"Team.bio",
"Team.tag",
"Team.customUrl",
"Team.customTheme",
"Team.avatarImgId",
"Team.bannerImgId",
concatUserSubmittedImagePrefix(
includeUnvalidatedImages
? eb.ref("AvatarImage.url")
: eb.fn<string | null>("iif", [
eb("AvatarImage.validatedAt", "is not", null),
eb.ref("AvatarImage.url"),
sql`null`,
]),
).as("avatarUrl"),
concatUserSubmittedImagePrefix(
includeUnvalidatedImages
? eb.ref("BannerImage.url")
: eb.fn<string | null>("iif", [
eb("BannerImage.validatedAt", "is not", null),
eb.ref("BannerImage.url"),
sql`null`,
]),
).as("bannerUrl"),
jsonArrayFrom(
eb
.selectFrom("TeamMemberWithSecondary")
.innerJoin("User", "User.id", "TeamMemberWithSecondary.userId")
.select(({ eb: innerEb }) => [
...commonUserSelect(innerEb),
"TeamMemberWithSecondary.role",
"TeamMemberWithSecondary.customRole",
"TeamMemberWithSecondary.roleType",
"TeamMemberWithSecondary.isOwner",
"TeamMemberWithSecondary.isManager",
"TeamMemberWithSecondary.isMainTeam",
"User.country",
"User.patronTier",
userProfileWeapons(innerEb).as("weapons"),
])
.whereRef("TeamMemberWithSecondary.teamId", "=", "Team.id")
.orderBy("TeamMemberWithSecondary.order", "asc"),
).as("members"),
])
.$if(includeInviteCode, (qb) => qb.select("Team.inviteCode"))
.$if(includeMapModePreferences, (qb) =>
qb.select("Team.mapModePreferences"),
)
.where("Team.customUrl", "=", customUrl.toLowerCase())
.executeTakeFirst();
if (!row) return;
const managerIds = row.members
.filter((member) => member.isOwner || member.isManager)
.map((member) => member.id);
return {
...row,
permissions: {
EDIT: managerIds,
MANAGE_ROSTER: managerIds,
DELETE: row.members
.filter((member) => member.isOwner)
.map((member) => member.id),
},
};
}
export type FindResultPlacementsById = NonNullable<
Awaited<ReturnType<typeof findResultPlacementsById>>
>;
export function findResultPlacementsById(teamId: number) {
return db
.selectFrom("TournamentTeam")
.innerJoin(
"TournamentResult",
"TournamentResult.tournamentTeamId",
"TournamentTeam.id",
)
.select(["TournamentResult.placement"])
.where("teamId", "=", teamId)
.groupBy("TournamentResult.tournamentId")
.execute();
}
/**
* Retrieves tournament results for a given team by its ID.
*/
export async function findResultsById(teamId: number) {
const rows = await db
.with("results", (db) =>
db
.selectFrom("TournamentTeam")
.innerJoin(
"TournamentResult",
"TournamentResult.tournamentTeamId",
"TournamentTeam.id",
)
.select([
"TournamentResult.userId",
"TournamentResult.tournamentTeamId",
"TournamentResult.tournamentId",
"TournamentResult.placement",
"TournamentResult.participantCount",
])
.where("teamId", "=", teamId)
.groupBy("TournamentResult.tournamentId"),
)
.selectFrom("results")
.innerJoin(
"CalendarEvent",
"CalendarEvent.tournamentId",
"results.tournamentId",
)
.innerJoin(
"CalendarEventDate",
"CalendarEventDate.eventId",
"CalendarEvent.id",
)
.innerJoin("Tournament", "Tournament.id", "results.tournamentId")
.select((eb) => [
"results.placement",
"results.tournamentId",
"results.participantCount",
"results.tournamentTeamId",
"CalendarEvent.name as tournamentName",
"CalendarEventDate.startsAt",
"Tournament.tier",
tournamentLogoOrNull(eb).as("logoUrl"),
jsonArrayFrom(
eb
.selectFrom("results as results2")
.innerJoin("TournamentResult", (join) =>
join
.onRef(
"TournamentResult.tournamentTeamId",
"=",
"results2.tournamentTeamId",
)
.onRef(
"TournamentResult.tournamentId",
"=",
"results2.tournamentId",
),
)
.innerJoin("User", "User.id", "TournamentResult.userId")
.whereRef("results2.tournamentId", "=", "results.tournamentId")
.select((eb) => commonUserSelect(eb)),
).as("participants"),
])
.orderBy("CalendarEventDate.startsAt", "desc")
.execute();
const members = await allMembersById(teamId);
return rows.map((row) => {
const subs = subsOfResult(row, members);
return {
...row,
subs,
};
});
}
// reads AllTeamMember instead of the TeamMemberWithSecondary view because the
// view filters out members who have left, and past members are exactly what
// subsOfResult needs to tell substitutes apart from since-departed roster members
function allMembersById(teamId: number) {
return db
.selectFrom("AllTeamMember")
.select([
"AllTeamMember.userId",
"AllTeamMember.leftAt",
"AllTeamMember.createdAt",
])
.where("AllTeamMember.teamId", "=", teamId)
.execute();
}
export async function findAllByMemberUserId(
userId: number,
trx?: Transaction<DB>,
) {
return (trx ?? db)
.selectFrom("TeamMemberWithSecondary")
.innerJoin("Team", "Team.id", "TeamMemberWithSecondary.teamId")
.select((eb) => [
"TeamMemberWithSecondary.teamId as id",
"Team.name",
"TeamMemberWithSecondary.isOwner",
"TeamMemberWithSecondary.isMainTeam",
jsonArrayFrom(
eb
.selectFrom("TeamMemberWithSecondary as m2")
.innerJoin("User", "User.id", "m2.userId")
.select((eb) => [...commonUserSelect(eb), "m2.role", "m2.roleType"])
.whereRef("TeamMemberWithSecondary.teamId", "=", "m2.teamId")
.orderBy("m2.order", "asc"),
).as("members"),
])
.where("userId", "=", userId)
.execute();
}
export async function insert(
args: Pick<Insertable<Tables["Team"]>, "name"> & {
ownerUserId: number;
isMainTeam: boolean;
},
) {
const customUrl = mySlugify(args.name);
return db.transaction().execute(async (trx) => {
const team = await trx
.insertInto("AllTeam")
.values({
name: args.name,
customUrl,
inviteCode: shortNanoid(),
})
.returning(["id", "customUrl"])
.executeTakeFirstOrThrow();
await trx
.insertInto("AllTeamMember")
.values({
userId: args.ownerUserId,
teamId: team.id,
isOwner: 1,
isMainTeam: toDBBoolean(args.isMainTeam),
})
.execute();
return team;
});
}
export async function update({
id,
name,
bio,
bsky,
tag,
avatarImgId,
bannerImgId,
}: Pick<
Insertable<Tables["Team"]>,
"id" | "name" | "bio" | "bsky" | "tag" | "avatarImgId" | "bannerImgId"
>) {
const customUrl = mySlugify(name);
return db.transaction().execute(async (trx) => {
const current = await trx
.selectFrom("Team")
.select(["avatarImgId", "bannerImgId"])
.where("id", "=", id)
.executeTakeFirst();
// images that got removed or replaced are no longer referenced by anything,
// so their submitted image rows are cleaned up
const orphanedImageIds: number[] = [];
if (current?.avatarImgId && current.avatarImgId !== avatarImgId) {
orphanedImageIds.push(current.avatarImgId);
}
if (current?.bannerImgId && current.bannerImgId !== bannerImgId) {
orphanedImageIds.push(current.bannerImgId);
}
if (orphanedImageIds.length > 0) {
await trx
.deleteFrom("UnvalidatedUserSubmittedImage")
.where("id", "in", orphanedImageIds)
.execute();
}
return trx
.updateTable("AllTeam")
.set({
name,
customUrl,
bio,
bsky,
tag,
avatarImgId,
bannerImgId,
})
.where("id", "=", id)
.returningAll()
.executeTakeFirstOrThrow();
});
}
export async function updateCustomTheme({
id,
customTheme,
}: {
id: number;
customTheme: CustomTheme | null;
}) {
await db
.updateTable("AllTeam")
.set({
customTheme: customTheme ? JSON.stringify(customTheme) : null,
})
.where("id", "=", id)
.execute();
}
/** Updates the team's SendouQ map/mode preferences, or clears them when passed `null`. Keeps existing map pools of modes missing from the new value. */
export async function updateMapModePreferences({
id,
mapModePreferences,
}: {
id: number;
mapModePreferences: UserMapModePreferences | null;
}) {
if (!mapModePreferences) {
await db
.updateTable("AllTeam")
.set({ mapModePreferences: null })
.where("id", "=", id)
.execute();
return;
}
const current = await db
.selectFrom("Team")
.select("Team.mapModePreferences")
.where("Team.id", "=", id)
.executeTakeFirstOrThrow();
const merged: UserMapModePreferences = {
...mapModePreferences,
pool: MatchProfileRepository.mergeExcludedModePreferences(
mapModePreferences.pool,
current.mapModePreferences?.pool,
),
};
await db
.updateTable("AllTeam")
.set({ mapModePreferences: JSON.stringify(merged) })
.where("id", "=", id)
.execute();
}
export function switchOwnMainTeam(teamId: number) {
const userId = actorId();
return db.transaction().execute(async (trx) => {
const currentTeams = await findAllByMemberUserId(userId, trx);
const teamToSwitchTo = currentTeams.find((team) => team.id === teamId);
invariant(teamToSwitchTo, "User is not a member of this team");
await trx
.updateTable("AllTeamMember")
.set({
isMainTeam: 0,
})
.where("userId", "=", userId)
.execute();
await trx
.updateTable("AllTeamMember")
.set({
isMainTeam: 1,
})
.where("userId", "=", userId)
.where("teamId", "=", teamId)
.execute();
});
}
export function deleteById(teamId: number) {
return db.transaction().execute(async (trx) => {
const members = await trx
.selectFrom("TeamMember")
.select(["TeamMember.userId"])
.where("teamId", "=", teamId)
.execute();
// switch main team to another if they at least one secondary team
for (const member of members) {
const currentTeams = await findAllByMemberUserId(member.userId, trx);
const teamToSwitchTo = currentTeams.find((team) => team.id !== teamId);
if (!teamToSwitchTo) continue;
await trx
.updateTable("AllTeamMember")
.set({
isMainTeam: 1,
})
.where("userId", "=", member.userId)
.where("teamId", "=", teamToSwitchTo.id)
.execute();
}
await trx
.updateTable("AllTeamMember")
.set({
isMainTeam: 0,
})
.where("AllTeamMember.teamId", "=", teamId)
.execute();
await LFGRepository.deletePostsByTeamId(teamId, trx);
await trx
.updateTable("AllTeam")
.set({
deletedAt: databaseTimestampNow(),
})
.where("id", "=", teamId)
.execute();
});
}
export function resetInviteCode(teamId: number) {
return db
.updateTable("AllTeam")
.set({
inviteCode: shortNanoid(),
})
.where("id", "=", teamId)
.execute();
}
export function insertOwnMembership({
teamId,
maxTeamsAllowed,
}: {
teamId: number;
maxTeamsAllowed: number;
}) {
const userId = actorId();
return db.transaction().execute(async (trx) => {
const teamCount = (await findAllByMemberUserId(userId, trx)).length;
if (teamCount >= maxTeamsAllowed) {
throw new Error("Trying to exceed allowed team count");
}
const isMainTeam = toDBBoolean(teamCount === 0);
const maxOrder = await trx
.selectFrom("AllTeamMember")
.select((eb) =>
eb.fn.coalesce(eb.fn.max("order"), sql<number>`-1`).as("maxOrder"),
)
.where("teamId", "=", teamId)
.where("leftAt", "is", null)
.executeTakeFirst();
const order = (maxOrder?.maxOrder ?? -1) + 1;
await trx
.insertInto("AllTeamMember")
.values({ userId, teamId, isMainTeam, order })
.onConflict((oc) =>
oc.columns(["userId", "teamId"]).doUpdateSet({
leftAt: null,
isMainTeam,
order,
}),
)
.execute();
});
}
export function handleMemberLeaving({
userId,
teamId,
newOwnerUserId,
}: {
userId: number;
teamId: number;
newOwnerUserId?: number;
}) {
return db
.transaction()
.execute((trx) => memberLeave(trx, { userId, teamId, newOwnerUserId }));
}
/**
* Applies a roster edit in a single transaction: updates each kept member's role
* & editor status and removes (kicks) the members in `kickedUserIds`.
*/
export function updateRoster({
teamId,
members,
kickedUserIds,
}: {
teamId: number;
members: Array<{
userId: number;
role: Tables["TeamMember"]["role"];
customRole: Tables["TeamMember"]["customRole"];
roleType: Tables["TeamMember"]["roleType"];
isManager: boolean;
order: number;
}>;
kickedUserIds: number[];
}) {
return db.transaction().execute(async (trx) => {
for (const userId of kickedUserIds) {
await memberLeave(trx, { userId, teamId });
}
for (const member of members) {
await trx
.updateTable("AllTeamMember")
.set({
role: member.role,
customRole: member.customRole,
roleType: member.roleType,
isManager: member.isManager ? 1 : 0,
order: member.order,
})
.where("teamId", "=", teamId)
.where("userId", "=", member.userId)
.execute();
}
});
}
async function memberLeave(
trx: Transaction<DB>,
{
userId,
teamId,
newOwnerUserId,
}: { userId: number; teamId: number; newOwnerUserId?: number },
) {
const currentTeams = await findAllByMemberUserId(userId, trx);
const teamToLeave = currentTeams.find((team) => team.id === teamId);
invariant(teamToLeave, "User is not a member of this team");
invariant(
!teamToLeave.isOwner || newOwnerUserId,
"New owner id must be provided when old is leaving",
);
const wasMainTeam = teamToLeave.isMainTeam;
const newMainTeam = currentTeams.find((team) => team.id !== teamId);
if (wasMainTeam && newMainTeam) {
await trx
.updateTable("AllTeamMember")
.set({
isMainTeam: 1,
})
.where("userId", "=", userId)
.where("teamId", "=", newMainTeam.id)
.execute();
}
await trx
.updateTable("AllTeamMember")
.set({
leftAt: databaseTimestampNow(),
isMainTeam: 0,
isOwner: 0,
isManager: 0,
})
.where("userId", "=", userId)
.where("teamId", "=", teamId)
.execute();
if (newOwnerUserId) {
await trx
.updateTable("AllTeamMember")
.set({
isOwner: 1,
isManager: 0,
})
.where("userId", "=", newOwnerUserId)
.where("teamId", "=", teamId)
.execute();
}
}

View File

@@ -0,0 +1,186 @@
import { beforeEach, describe, expect, test } from "vitest";
import { REGULAR_USER_TEST_ID } from "~/db/seed/constants";
import * as TeamFactory from "~/db/seed/factories/TeamFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import * as ImageRepository from "~/features/img-upload/ImageRepository.server";
import * as TeamRepository from "~/features/team/TeamRepository.server";
import invariant from "~/utils/invariant";
import { clampThemeToGamut } from "~/utils/oklch-gamut";
import { wrappedAction } from "~/utils/Test";
import type { editTeamActionSchema } from "../team-schemas";
import { action as _editTeamProfileAction } from "./t.$customUrl.edit.server";
const editTeamProfileAction = wrappedAction<typeof editTeamActionSchema>({
action: _editTeamProfileAction,
isJsonSubmission: true,
});
const DEFAULT_EDIT_FIELDS = {
_action: "EDIT",
name: "Team 1",
bio: "",
bsky: "",
tag: "",
logo: null,
banner: null,
} as const;
const VALID_CUSTOM_THEME = {
baseHue: 180,
baseChroma: 0.05,
accentHue: 200,
accentChroma: 0.1,
chatHue: null,
radiusBox: 3,
radiusField: 2,
radiusSelector: 2,
borderWidth: 2,
sizeField: 1,
sizeSelector: 1,
sizeSpacing: 1,
} as const;
const expectedStoredTheme = () =>
JSON.parse(JSON.stringify(clampThemeToGamut(VALID_CUSTOM_THEME)));
describe("team page editing", () => {
let customUrl: string;
const createTeam = async (
options?: Parameters<typeof TeamFactory.create>[1],
) => {
const team = await TeamFactory.create(
{ name: "Team 1", memberUserIds: [REGULAR_USER_TEST_ID] },
options,
);
customUrl = team.customUrl;
};
const teamRow = async () => {
const team = await TeamRepository.findByCustomUrl(customUrl);
invariant(team, `No team with the custom url ${customUrl}`);
return team;
};
beforeEach(async () => {
// a patron because setting a custom theme is a patron only feature
await UserFactory.createRegular(null, { patronTier: 2 });
});
describe("custom theme", () => {
beforeEach(() => createTeam());
test("sets a custom theme via UPDATE_CUSTOM_THEME", async () => {
const response = await editTeamProfileAction(
{
_action: "UPDATE_CUSTOM_THEME",
newValue: VALID_CUSTOM_THEME,
},
{ user: "regular", params: { customUrl } },
);
expect(response).toEqual({ ok: true });
expect((await teamRow()).customTheme).toEqual(expectedStoredTheme());
});
test("clears a custom theme via UPDATE_CUSTOM_THEME with null", async () => {
await editTeamProfileAction(
{
_action: "UPDATE_CUSTOM_THEME",
newValue: VALID_CUSTOM_THEME,
},
{ user: "regular", params: { customUrl } },
);
const response = await editTeamProfileAction(
{
_action: "UPDATE_CUSTOM_THEME",
newValue: null,
},
{ user: "regular", params: { customUrl } },
);
expect(response).toEqual({ ok: true });
expect((await teamRow()).customTheme).toBeNull();
});
test("prevents setting an invalid custom theme", async () => {
const response = await editTeamProfileAction(
{
_action: "UPDATE_CUSTOM_THEME",
newValue: {
...VALID_CUSTOM_THEME,
baseHue: 500, // Invalid: max is 360
},
},
{ user: "regular", params: { customUrl } },
);
expect(response.fieldErrors["newValue.baseHue"]).toBeTruthy();
});
test("preserves an existing custom theme when editing the team profile", async () => {
await editTeamProfileAction(
{
_action: "UPDATE_CUSTOM_THEME",
newValue: VALID_CUSTOM_THEME,
},
{ user: "regular", params: { customUrl } },
);
const response = await editTeamProfileAction(
{ ...DEFAULT_EDIT_FIELDS, bio: "Updated bio" },
{ user: "regular", params: { customUrl } },
);
expect(response.status).toBe(302);
const team = await teamRow();
expect(team.customTheme).toEqual(expectedStoredTheme());
expect(team.bio).toBe("Updated bio");
});
});
describe("logo", () => {
let imageId: number;
const imageExists = async (id: number) =>
Boolean(await ImageRepository.findById(id));
beforeEach(async () => {
await createTeam({ hasAvatar: true });
const avatarImgId = (await teamRow()).avatarImgId;
invariant(avatarImgId, "The team was created without a logo");
imageId = avatarImgId;
});
test("deletes the submitted image row when an image is removed while editing", async () => {
await editTeamProfileAction(
{ ...DEFAULT_EDIT_FIELDS },
{ user: "regular", params: { customUrl } },
);
expect((await teamRow()).avatarImgId).toBeNull();
expect(await imageExists(imageId)).toBe(false);
});
test("keeps the submitted image row when an existing image is unchanged", async () => {
await editTeamProfileAction(
{
...DEFAULT_EDIT_FIELDS,
logo: {
type: "EXISTING",
imgId: imageId,
url: "https://example.com/test-avatar.jpg",
},
},
{ user: "regular", params: { customUrl } },
);
expect((await teamRow()).avatarImgId).toBe(imageId);
expect(await imageExists(imageId)).toBe(true);
});
});
});

View File

@@ -0,0 +1,93 @@
import type { ActionFunction } from "react-router";
import { redirect } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import { parseFormDataWithImages } from "~/form/parse.server";
import { requirePermission } from "~/modules/permissions/guards.server";
import { clampThemeToGamut } from "~/utils/oklch-gamut";
import { errorToastIfFalsy, notFoundIfNullish } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { mySlugify, teamPage } from "~/utils/urls";
import * as TeamRepository from "../TeamRepository.server";
import { editTeamActionSchema } from "../team-schemas";
import { teamParamsSchema } from "../team-schemas.server";
import { canAddCustomizedColors } from "../team-utils";
export const action: ActionFunction = async ({ request, params }) => {
requireUser();
const { customUrl } = teamParamsSchema.parse(params);
const team = notFoundIfNullish(
await TeamRepository.findByCustomUrl(customUrl),
);
requirePermission(team, "EDIT");
const result = await parseFormDataWithImages({
request,
schema: editTeamActionSchema,
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
const data = result.data;
switch (data._action) {
case "UPDATE_CUSTOM_THEME": {
errorToastIfFalsy(
canAddCustomizedColors(team),
"Team does not have custom theme access",
);
await TeamRepository.updateCustomTheme({
id: team.id,
customTheme: data.newValue ? clampThemeToGamut(data.newValue) : null,
});
return { ok: true };
}
case "UPDATE_MAP_MODE_PREFERENCES": {
await TeamRepository.updateMapModePreferences({
id: team.id,
mapModePreferences: data.mapModePreferences,
});
return { ok: true };
}
case "REMOVE_MAP_MODE_PREFERENCES": {
await TeamRepository.updateMapModePreferences({
id: team.id,
mapModePreferences: null,
});
return { ok: true };
}
case "EDIT": {
const newCustomUrl = mySlugify(data.name);
const teams = await TeamRepository.findAllUndisbanded();
const duplicateTeam = teams.find(
(t) => t.customUrl === newCustomUrl && t.customUrl !== team.customUrl,
);
if (duplicateTeam) {
return { fieldErrors: { name: "forms:errors.duplicateName" } };
}
const updatedTeam = await TeamRepository.update({
id: team.id,
name: data.name,
bio: data.bio,
bsky: data.bsky,
tag: data.tag,
avatarImgId: data.logo,
bannerImgId: data.banner,
});
throw redirect(teamPage(updatedTeam.customUrl));
}
default: {
assertUnreachable(data);
}
}
};

View File

@@ -0,0 +1,68 @@
import type { ActionFunction } from "react-router";
import { redirect } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import { requirePermission } from "~/modules/permissions/guards.server";
import {
errorToastIfFalsy,
notFoundIfNullish,
parseRequestPayload,
} from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import * as TeamRepository from "../TeamRepository.server";
import { teamProfilePageActionSchema } from "../team-schemas";
import { teamParamsSchema } from "../team-schemas.server";
import { isTeamMember, isTeamOwner, resolveNewOwner } from "../team-utils";
export const action: ActionFunction = async ({ request, params }) => {
const user = requireUser();
const data = await parseRequestPayload({
request,
schema: teamProfilePageActionSchema,
});
const { customUrl } = teamParamsSchema.parse(params);
const team = notFoundIfNullish(
await TeamRepository.findByCustomUrl(customUrl),
);
switch (data._action) {
case "LEAVE_TEAM": {
errorToastIfFalsy(
isTeamMember({ user, team }),
"You are not a member of this team",
);
const newOwner = isTeamOwner({ user, team })
? resolveNewOwner(team.members)
: null;
errorToastIfFalsy(
!isTeamOwner({ user, team }) || newOwner,
"You can't leave the team if you are the owner and there is no other member to become the owner",
);
await TeamRepository.handleMemberLeaving({
teamId: team.id,
userId: user.id,
newOwnerUserId: newOwner?.id,
});
break;
}
case "MAKE_MAIN_TEAM": {
await TeamRepository.switchOwnMainTeam(team.id);
break;
}
case "DELETE_TEAM": {
requirePermission(team, "DELETE");
await TeamRepository.deleteById(team.id);
throw redirect("/");
}
default: {
assertUnreachable(data);
}
}
return null;
};

View File

@@ -0,0 +1,44 @@
import { type ActionFunction, redirect } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import { errorToastIfFalsy, notFoundIfNullish } from "~/utils/remix.server";
import { teamPage } from "~/utils/urls";
import { validateInviteCode } from "../loaders/t.$customUrl.join.server";
import * as TeamRepository from "../TeamRepository.server";
import { TEAM } from "../team-constants";
import { teamParamsSchema } from "../team-schemas.server";
import { teamJoinSearchParams } from "../team-search-params";
export const action: ActionFunction = async ({ params, url }) => {
const user = requireUser();
const { customUrl } = teamParamsSchema.parse(params);
const team = notFoundIfNullish(
await TeamRepository.findByCustomUrl(customUrl, {
includeInviteCode: true,
}),
);
const { code } = teamJoinSearchParams.parse(url);
const realInviteCode = team.inviteCode!;
errorToastIfFalsy(
validateInviteCode({
inviteCode: code ?? "",
realInviteCode,
team,
user,
reachedTeamCountLimit: false, // checked in the DB transaction
}) === "VALID",
"Invite code is invalid",
);
await TeamRepository.insertOwnMembership({
maxTeamsAllowed:
user.patronTier && user.patronTier >= 2
? TEAM.MAX_TEAM_COUNT_PATRON
: TEAM.MAX_TEAM_COUNT_NON_PATRON,
teamId: team.id,
});
throw redirect(teamPage(team.customUrl));
};

View File

@@ -0,0 +1,97 @@
import type { ActionFunction } from "react-router";
import { redirect } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import type {
MemberRole,
MemberRoleType,
} from "~/features/team/team-constants";
import { parseFormData } from "~/form/parse.server";
import { requirePermission } from "~/modules/permissions/guards.server";
import { errorToastIfFalsy, notFoundIfNullish } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { teamPage } from "~/utils/urls";
import * as TeamRepository from "../TeamRepository.server";
import { CUSTOM_ROLE_VALUE } from "../team-schemas";
import { manageRosterSchema, teamParamsSchema } from "../team-schemas.server";
export const action: ActionFunction = async ({ request, params }) => {
const user = requireUser();
const { customUrl } = teamParamsSchema.parse(params);
const team = notFoundIfNullish(
await TeamRepository.findByCustomUrl(customUrl),
);
requirePermission(team, "MANAGE_ROSTER");
const result = await parseFormData({
request,
schema: manageRosterSchema,
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
const data = result.data;
switch (data._action) {
case "RESET_INVITE_LINK": {
await TeamRepository.resetInviteCode(team.id);
break;
}
case "UPDATE_ROSTER": {
const submittedIds = new Set(data.members.map((m) => m.userId));
const existingMembersById = new Map(
team.members.map((member) => [member.id, member]),
);
for (const member of data.members) {
errorToastIfFalsy(
existingMembersById.has(member.userId),
"Member not found",
);
}
const kickedUserIds = team.members
.filter((member) => !submittedIds.has(member.id))
.map((member) => {
errorToastIfFalsy(!member.isOwner, "Can't kick the owner");
errorToastIfFalsy(member.id !== user.id, "Can't kick yourself");
return member.id;
});
await TeamRepository.updateRoster({
teamId: team.id,
members: data.members.map((member, index) => {
const isCustom = member.role === CUSTOM_ROLE_VALUE;
const existing = existingMembersById.get(member.userId);
const isProtectedMember = Boolean(
existing?.isOwner || existing?.id === user.id,
);
return {
userId: member.userId,
role: isCustom
? null
: ((member.role || null) as MemberRole | null),
customRole: isCustom ? member.customRole || null : null,
roleType: isCustom
? ((member.roleType || null) as MemberRoleType | null)
: null,
isManager: isProtectedMember
? Boolean(existing?.isManager)
: member.isManager,
order: index,
};
}),
kickedUserIds,
});
return redirect(teamPage(customUrl));
}
default: {
assertUnreachable(data);
}
}
return null;
};

View File

@@ -0,0 +1,29 @@
import { beforeEach, describe, expect, test } from "vitest";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import { wrappedAction } from "~/utils/Test";
import { action as teamIndexPageAction } from "../actions/t.new.server";
import type { createTeamSchema } from "../team-schemas";
const action = wrappedAction<typeof createTeamSchema>({
action: teamIndexPageAction,
isJsonSubmission: true,
});
describe("team creation", () => {
beforeEach(async () => {
await UserFactory.createRegular();
});
test("prevents creating a team with a duplicate name", async () => {
await action({ name: "Team 1" }, { user: "regular" });
const res = await action({ name: "Team 1" }, { user: "regular" });
expect(res.fieldErrors.name).toBe("forms:errors.duplicateName");
});
test("prevents creating a team whose name is only special characters", async () => {
const res = await action({ name: "𝓢𝓲𝓵" }, { user: "regular" });
expect(res.fieldErrors.name).toBe("forms:errors.noOnlySpecialCharacters");
});
});

View File

@@ -0,0 +1,45 @@
import type { ActionFunction } from "react-router";
import { redirect } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import { parseFormData } from "~/form/parse.server";
import { errorToastIfFalsy } from "~/utils/remix.server";
import { teamPage } from "~/utils/urls";
import * as TeamRepository from "../TeamRepository.server";
import { TEAM } from "../team-constants";
import { createTeamSchemaServer } from "../team-schemas.server";
export const action: ActionFunction = async ({ request }) => {
const user = requireUser();
const result = await parseFormData({
request,
schema: createTeamSchemaServer,
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
const data = result.data;
const teams = await TeamRepository.findAllUndisbanded();
const currentTeamCount = teams.filter((team) =>
team.members.some((m) => m.id === user.id),
).length;
const maxTeamCount = user.roles.includes("SUPPORTER")
? TEAM.MAX_TEAM_COUNT_PATRON
: TEAM.MAX_TEAM_COUNT_NON_PATRON;
errorToastIfFalsy(
currentTeamCount < maxTeamCount,
"Already in max amount of teams",
);
const team = await TeamRepository.insert({
ownerUserId: user.id,
name: data.name,
isMainTeam: currentTeamCount === 0,
});
throw redirect(teamPage(team.customUrl));
};

View File

@@ -0,0 +1,29 @@
import { ChevronLeft } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useMatches } from "react-router";
import { LinkButton } from "~/components/elements/Button";
import type { TeamLoaderData } from "~/features/team/loaders/t.$customUrl.server";
import invariant from "~/utils/invariant";
import { teamPage } from "~/utils/urls";
export function TeamGoBackButton() {
const { t } = useTranslation(["common"]);
const [, parentRoute] = useMatches();
invariant(parentRoute);
const layoutData = parentRoute.loaderData as TeamLoaderData;
return (
<div className="stack">
<LinkButton
to={teamPage(layoutData.team.customUrl)}
icon={<ChevronLeft />}
variant="outlined"
size="small"
className="mr-auto"
>
{t("common:actions.back")}
</LinkButton>
</div>
);
}

View File

@@ -0,0 +1,7 @@
.players {
display: flex;
flex-direction: column;
padding: 0;
gap: var(--s-3);
list-style: none;
}

View File

@@ -0,0 +1,108 @@
import { Users } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { LocaleTime } from "~/components/LocaleTime";
import { Placement } from "~/components/Placement";
import { Table } from "~/components/Table";
import { TierPill } from "~/components/TierPill";
import { UserLink } from "~/components/UserLink";
import type { TeamResultsLoaderData } from "~/features/team/loaders/t.$customUrl.results.server";
import { tournamentTeamPage } from "~/utils/urls";
import styles from "./TeamResultsTable.module.css";
interface TeamResultsTableProps {
results: TeamResultsLoaderData["results"];
}
export function TeamResultsTable({ results }: TeamResultsTableProps) {
const { t } = useTranslation("user");
return (
<Table>
<thead>
<tr>
<th>{t("results.placing")}</th>
<th>{t("results.date")}</th>
<th>{t("results.tournament")}</th>
<th>{t("results.subs")}</th>
</tr>
</thead>
<tbody>
{results.map((result) => {
return (
<tr key={result.tournamentId}>
<td className="pl-4 whitespace-nowrap">
<div className="stack horizontal xs items-end">
<Placement placement={result.placement} />{" "}
<div className="text-lighter">
/ {result.participantCount}
</div>
</div>
</td>
<td className="whitespace-nowrap">
<LocaleTime
date={result.startsAt}
options={{
day: "numeric",
month: "numeric",
year: "numeric",
}}
/>
</td>
<td>
<div className="stack horizontal xs items-center">
{result.logoUrl ? (
<img
src={result.logoUrl}
alt=""
width={18}
height={18}
className="rounded-full"
/>
) : null}
<Link
to={tournamentTeamPage({
tournamentTeamId: result.tournamentTeamId,
tournamentId: result.tournamentId,
})}
>
{result.tournamentName}
</Link>
{result.tier ? <TierPill tier={result.tier} /> : null}
</div>
</td>
<td>
{result.subs.length > 0 ? (
<div className="stack horizontal md items-center">
<SendouPopover
trigger={
<SendouButton
icon={<Users />}
size="small"
variant="minimal"
>
{result.subs.length}
</SendouButton>
}
>
<ul className={styles.players}>
{result.subs.map((player) => (
<li key={player.id} className="flex items-center">
<UserLink user={player} />
</li>
))}
</ul>
</SendouPopover>
</div>
) : null}
</td>
</tr>
);
})}
</tbody>
</Table>
);
}

View File

@@ -0,0 +1,31 @@
import type { LoaderFunctionArgs } from "react-router";
import { redirect } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import { hasPermission } from "~/modules/permissions/utils";
import { notFoundIfNullish } from "~/utils/remix.server";
import { teamPage } from "~/utils/urls";
import * as TeamRepository from "../TeamRepository.server";
import { teamParamsSchema } from "../team-schemas.server";
import { canAddCustomizedColors } from "../team-utils";
export const loader = async ({ params }: LoaderFunctionArgs) => {
const user = requireUser();
const { customUrl } = teamParamsSchema.parse(params);
const team = notFoundIfNullish(
await TeamRepository.findByCustomUrl(customUrl, {
includeUnvalidatedImages: true,
includeMapModePreferences: true,
}),
);
if (!hasPermission(team, "EDIT", user)) {
throw redirect(teamPage(customUrl));
}
return {
team,
customTheme: canAddCustomizedColors(team) ? team.customTheme : null,
canAddCustomizedColors: canAddCustomizedColors(team),
};
};

View File

@@ -0,0 +1,80 @@
import type { LoaderFunctionArgs } from "react-router";
import { redirect } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import { SHORT_NANOID_LENGTH } from "~/utils/id";
import { notFoundIfNullish } from "~/utils/remix.server";
import { teamPage } from "~/utils/urls";
import * as TeamRepository from "../TeamRepository.server";
import { TEAM } from "../team-constants";
import { teamParamsSchema } from "../team-schemas.server";
import { teamJoinSearchParams } from "../team-search-params";
import { isTeamFull, isTeamMember } from "../team-utils";
export const loader = async ({ params, url }: LoaderFunctionArgs) => {
const user = requireUser();
const { customUrl } = teamParamsSchema.parse(params);
const team = notFoundIfNullish(
await TeamRepository.findByCustomUrl(customUrl, {
includeInviteCode: true,
}),
);
const { code } = teamJoinSearchParams.parse(url);
const realInviteCode = team.inviteCode!;
const teamCount = (await TeamRepository.findAllByMemberUserId(user.id))
.length;
const validation = validateInviteCode({
inviteCode: code ?? "",
realInviteCode,
team,
user,
reachedTeamCountLimit:
user.patronTier && user.patronTier >= 2
? teamCount >= TEAM.MAX_TEAM_COUNT_PATRON
: teamCount >= TEAM.MAX_TEAM_COUNT_NON_PATRON,
});
if (validation === "ALREADY_JOINED") {
throw redirect(teamPage(team.customUrl));
}
return {
validation,
teamName: team.name,
};
};
export function validateInviteCode({
inviteCode,
realInviteCode,
team,
user,
reachedTeamCountLimit,
}: {
inviteCode: string;
realInviteCode: string;
team: TeamRepository.findByCustomUrl;
user?: { id: number; team?: { name: string } };
reachedTeamCountLimit: boolean;
}) {
if (inviteCode.length !== SHORT_NANOID_LENGTH) {
return "SHORT_CODE";
}
if (inviteCode !== realInviteCode) {
return "INVITE_CODE_WRONG";
}
if (isTeamFull(team)) {
return "TEAM_FULL";
}
if (isTeamMember({ team, user })) {
return "ALREADY_JOINED";
}
if (reachedTeamCountLimit) {
return "REACHED_TEAM_COUNT_LIMIT";
}
return "VALID";
}

View File

@@ -0,0 +1,21 @@
import type { LoaderFunctionArgs } from "react-router";
import type { SerializeFrom } from "~/utils/remix";
import { notFoundIfNullish } from "~/utils/remix.server";
import * as TeamRepository from "../TeamRepository.server";
import { teamParamsSchema } from "../team-schemas.server";
export type TeamResultsLoaderData = SerializeFrom<typeof loader>;
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { customUrl } = teamParamsSchema.parse(params);
const team = notFoundIfNullish(
await TeamRepository.findByCustomUrl(customUrl),
);
const results = await TeamRepository.findResultsById(team.id);
return {
results,
};
};

View File

@@ -0,0 +1,21 @@
import type { LoaderFunctionArgs } from "react-router";
import { requirePermission } from "~/modules/permissions/guards.server";
import { notFoundIfNullish } from "~/utils/remix.server";
import * as TeamRepository from "../TeamRepository.server";
import { teamParamsSchema } from "../team-schemas.server";
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { customUrl } = teamParamsSchema.parse(params);
const team = notFoundIfNullish(
await TeamRepository.findByCustomUrl(customUrl, {
includeInviteCode: true,
}),
);
requirePermission(team, "MANAGE_ROSTER");
return {
team,
};
};

View File

@@ -0,0 +1,52 @@
import type { LoaderFunctionArgs } from "react-router";
import type { SerializeFrom } from "~/utils/remix";
import { notFoundIfNullish } from "~/utils/remix.server";
import * as TeamRepository from "../TeamRepository.server";
import { teamParamsSchema } from "../team-schemas.server";
import { canAddCustomizedColors } from "../team-utils";
export type TeamLoaderData = SerializeFrom<typeof loader>;
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { customUrl } = teamParamsSchema.parse(params);
const team = notFoundIfNullish(
await TeamRepository.findByCustomUrl(customUrl),
);
const results = await TeamRepository.findResultPlacementsById(team.id);
return {
team,
customTheme: canAddCustomizedColors(team) ? team.customTheme : null,
results: resultsMapped(results),
};
};
function resultsMapped(results: TeamRepository.FindResultPlacementsById) {
if (results.length === 0) {
return null;
}
const firstPlaceResults = results.filter((result) => result.placement === 1);
const secondPlaceResults = results.filter((result) => result.placement === 2);
const thirdPlaceResults = results.filter((result) => result.placement === 3);
return {
count: results.length,
placements: [
{
placement: 1,
count: firstPlaceResults.length,
},
{
placement: 2,
count: secondPlaceResults.length,
},
{
placement: 3,
count: thirdPlaceResults.length,
},
],
};
}

View File

@@ -0,0 +1,13 @@
import { requireUser } from "~/features/auth/core/user.server";
import * as TeamRepository from "../TeamRepository.server";
export const loader = async () => {
const user = requireUser();
const teams = await TeamRepository.findAllUndisbanded();
const teamMemberOfCount = teams.filter((team) =>
team.members.some((m) => m.id === user.id),
).length;
return { teamMemberOfCount };
};

View File

@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, test } from "vitest";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import { wrappedAction } from "~/utils/Test";
import { action as _editTeamAction } from "../routes/t.$customUrl.edit";
import type { editTeamFormSchema } from "../team-schemas";
import { createTeamOwnedByRegular } from "../tests/fixtures";
const editTeamAction = wrappedAction<typeof editTeamFormSchema>({
action: _editTeamAction,
isJsonSubmission: true,
});
const DEFAULT_FIELDS = {
tag: null,
bsky: null,
bio: null,
logo: null,
banner: null,
} as any;
describe("team name editing", () => {
beforeEach(async () => {
await UserFactory.createRegular();
});
test("can't take another team's name via editing", async () => {
const team = await createTeamOwnedByRegular("Team 1");
await createTeamOwnedByRegular("Team 2", false);
const res = await editTeamAction(
{
_action: "EDIT",
name: "Team 2",
...DEFAULT_FIELDS,
},
{ user: "regular", params: { customUrl: team.customUrl } },
);
expect(res.fieldErrors.name).toBe("forms:errors.duplicateName");
});
test("prevents editing team name to only special characters", async () => {
const team = await createTeamOwnedByRegular("Team 1");
const res = await editTeamAction(
{
_action: "EDIT",
name: "𝓢𝓲𝓵",
...DEFAULT_FIELDS,
},
{ user: "regular", params: { customUrl: team.customUrl } },
);
expect(res.fieldErrors.name).toBe("forms:errors.noOnlySpecialCharacters");
});
});

View File

@@ -0,0 +1,173 @@
import { Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { useLoaderData } from "react-router";
import { ActionButton } from "~/components/ActionButton";
import { CustomThemeSelector } from "~/components/CustomThemeSelector";
import { Divider } from "~/components/Divider";
import { Main, mainStyles } from "~/components/Main";
import type { UserMapModePreferences } from "~/db/tables-json";
import {
MapModePreferencesField,
preferencesFromRaw,
} from "~/features/settings/components/MapModePreferencesField";
import { TeamGoBackButton } from "~/features/team/components/TeamGoBackButton";
import { existingImage } from "~/form/image-field";
import { SendouForm } from "~/form/SendouForm";
import { useActionSubmit } from "~/hooks/useActionSubmit";
import type { ThemeInput } from "~/utils/oklch-gamut";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { action } from "../actions/t.$customUrl.edit.server";
import { loader } from "../loaders/t.$customUrl.edit.server";
import styles from "../team.module.css";
import {
editTeamActionSchema,
editTeamFormSchema,
updateTeamCustomThemeSchema,
updateTeamMapModePreferencesSchema,
} from "../team-schemas";
export { action, loader };
export const handle: SendouRouteHandle = {
i18n: ["settings"],
};
export const meta: MetaFunction = (args) => {
return metaTags({
title: "Editing team",
location: args.location,
});
};
export default function EditTeamPage() {
const { t } = useTranslation(["common", "team"]);
const { team, canAddCustomizedColors } = useLoaderData<typeof loader>();
return (
<Main className="stack lg">
<TeamGoBackButton />
<div className={mainStyles.narrow}>
<SendouForm
schema={editTeamFormSchema}
title={t("team:editTeam.header", { teamName: team.name })}
defaultValues={{
name: team.name,
tag: team.tag ?? "",
bsky: team.bsky ?? "",
bio: team.bio ?? "",
logo: existingImage(team.avatarImgId, team.avatarUrl),
banner: existingImage(team.bannerImgId, team.bannerUrl),
}}
submitButtonText={t("common:actions.submit")}
submitButtonTestId="edit-team-submit-button"
>
{({ FormField }) => (
<>
<FormField name="name" />
<FormField name="tag" />
<FormField name="bsky" />
<FormField name="bio" />
<FormField name="logo" />
<FormField name="banner" />
</>
)}
</SendouForm>
<Divider className={styles.mapPreferencesDivider} smallText>
{t("team:mapPreferences.header")}
</Divider>
<TeamMapModePreferences />
{canAddCustomizedColors ? (
<>
<Divider className={styles.formDivider} smallText>
{t("team:forms.customTheme.header")}
</Divider>
<TeamCustomThemeSelector />
</>
) : null}
</div>
</Main>
);
}
function TeamMapModePreferences() {
const { t } = useTranslation(["common", "team"]);
const { team } = useLoaderData<typeof loader>();
return (
<div className="stack md">
{team.mapModePreferences ? (
<ActionButton
schema={editTeamActionSchema}
action="REMOVE_MAP_MODE_PREFERENCES"
variant="minimal-destructive"
size="small"
icon={<Trash2 />}
className="mx-auto"
aria-label="Remove team map preferences"
confirm={{
dialogHeading: t("team:mapPreferences.remove.header", {
teamName: team.name,
}),
}}
>
{t("common:actions.remove")}
</ActionButton>
) : null}
<div className="text-lighter text-sm text-center">
{t("team:mapPreferences.explanation")}
</div>
<SendouForm
key={JSON.stringify(team.mapModePreferences ?? null)}
schema={updateTeamMapModePreferencesSchema}
defaultValues={{
mapModePreferences: preferencesFromRaw(team.mapModePreferences),
}}
submitButtonText={t("common:actions.save")}
submitButtonTestId="team-map-preferences-submit-button"
>
{({ FormField }) => (
<FormField name="mapModePreferences">
{(props: {
value: unknown;
onChange: (value: UserMapModePreferences) => void;
}) => (
<MapModePreferencesField
value={props.value as UserMapModePreferences}
onChange={props.onChange}
/>
)}
</FormField>
)}
</SendouForm>
</div>
);
}
function TeamCustomThemeSelector() {
const { customTheme, canAddCustomizedColors } =
useLoaderData<typeof loader>();
const { submit, state } = useActionSubmit(updateTeamCustomThemeSchema, {
encType: "application/json",
});
const handleSave = (themeInput: ThemeInput) => {
submit("UPDATE_CUSTOM_THEME", { newValue: themeInput });
};
const handleReset = () => {
submit("UPDATE_CUSTOM_THEME", { newValue: null });
};
return (
<CustomThemeSelector
initialTheme={customTheme}
isSupporter={canAddCustomizedColors}
isPersonalTheme={false}
onSave={handleSave}
onReset={handleReset}
fetcherState={state}
/>
);
}

View File

@@ -0,0 +1,409 @@
import {
LogOut,
Menu,
SquarePen,
Star,
Swords,
Trash2,
Users,
Wrench,
} from "lucide-react";
import React from "react";
import { useTranslation } from "react-i18next";
import { Link, useMatches } from "react-router";
import { Avatar } from "~/components/Avatar";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu";
import {
SendouTab,
SendouTabList,
SendouTabPanel,
SendouTabs,
} from "~/components/elements/Tabs";
import { WeaponImage } from "~/components/Image";
import { Placement } from "~/components/Placement";
import { UserLink } from "~/components/UserLink";
import { useUser } from "~/features/auth/core/user";
import type { TeamLoaderData } from "~/features/team/loaders/t.$customUrl.server";
import { useActionSubmit } from "~/hooks/useActionSubmit";
import { useHasPermission } from "~/modules/permissions/hooks";
import invariant from "~/utils/invariant";
import { editTeamPage, manageTeamRosterPage, userPage } from "~/utils/urls";
import { action } from "../actions/t.$customUrl.index.server";
import type * as TeamRepository from "../TeamRepository.server";
import styles from "../team.module.css";
import { teamProfilePageActionSchema } from "../team-schemas";
import {
getMemberRoleType,
isTeamMember,
isTeamOwner,
resolveNewOwner,
} from "../team-utils";
export { action };
export default function TeamIndexPage() {
const { t } = useTranslation(["team"]);
const [, parentRoute] = useMatches();
invariant(parentRoute);
const layoutData = parentRoute.loaderData as TeamLoaderData;
const members = layoutData.team.members;
const playerMembers = members.filter(
(member) => getMemberRoleType(member) !== "OTHER",
);
const otherMembers = members.filter(
(member) => getMemberRoleType(member) === "OTHER",
);
const showSections = playerMembers.length > 0 && otherMembers.length > 0;
const renderMember = (member: (typeof members)[number]) => (
<React.Fragment key={member.discordId}>
<MemberRow member={member} number={members.indexOf(member)} />
<MobileMemberCard member={member} />
</React.Fragment>
);
return (
<div className="stack lg">
<ActionButtons />
{layoutData.results ? (
<ResultsBanner results={layoutData.results} />
) : null}
{layoutData.team.bio ? (
<article data-testid="team-bio">{layoutData.team.bio}</article>
) : null}
{showSections ? (
<SendouTabs>
<SendouTabList>
<SendouTab
id="players"
icon={<Swords />}
number={playerMembers.length}
>
{t("team:roster.sections.players")}
</SendouTab>
<SendouTab
id="other"
icon={<Wrench />}
number={otherMembers.length}
>
{t("team:roster.sections.other")}
</SendouTab>
</SendouTabList>
<SendouTabPanel id="players">
<div className="stack lg">{playerMembers.map(renderMember)}</div>
</SendouTabPanel>
<SendouTabPanel id="other">
<div className="stack lg">{otherMembers.map(renderMember)}</div>
</SendouTabPanel>
</SendouTabs>
) : (
<div className="stack lg">{members.map(renderMember)}</div>
)}
</div>
);
}
function ActionButtons() {
const { t } = useTranslation(["team"]);
const user = useUser();
const [, parentRoute] = useMatches();
invariant(parentRoute);
const layoutData = parentRoute.loaderData as TeamLoaderData;
const team = layoutData.team;
const canManageRoster = useHasPermission(team, "MANAGE_ROSTER");
const canEditTeam = useHasPermission(team, "EDIT");
if (!isTeamMember({ user, team }) && !canManageRoster && !canEditTeam) {
return null;
}
return (
<div className={styles.actionButtons}>
{canManageRoster ? (
<LinkButton
size="small"
to={manageTeamRosterPage(team.customUrl)}
variant="outlined"
prefetch="intent"
icon={<Users />}
testId="manage-roster-button"
>
{t("team:actionButtons.manageRoster")}
</LinkButton>
) : null}
{canEditTeam ? (
<LinkButton
size="small"
to={editTeamPage(team.customUrl)}
variant="outlined"
prefetch="intent"
icon={<SquarePen />}
testId="edit-team-button"
>
{t("team:actionButtons.editTeam")}
</LinkButton>
) : null}
<TeamActionsMenu team={team} />
</div>
);
}
function TeamActionsMenu({ team }: { team: TeamLoaderData["team"] }) {
const { t } = useTranslation(["common", "team"]);
const user = useUser();
const { submit } = useActionSubmit(teamProfilePageActionSchema);
const [confirming, setConfirming] = React.useState<"LEAVE" | "DELETE" | null>(
null,
);
const isMainTeam = team.members.some(
(member) => user?.id === member.id && member.isMainTeam,
);
const showMainTeamIndicator = isTeamMember({ user, team }) && isMainTeam;
const canMakeMainTeam = isTeamMember({ user, team }) && !isMainTeam;
const canLeaveTeam = isTeamMember({ user, team }) && team.members.length > 1;
const canDeleteTeam = useHasPermission(team, "DELETE");
if (
!showMainTeamIndicator &&
!canMakeMainTeam &&
!canLeaveTeam &&
!canDeleteTeam
) {
return null;
}
const submitAction = (
action: "LEAVE_TEAM" | "MAKE_MAIN_TEAM" | "DELETE_TEAM",
) => {
submit(action);
setConfirming(null);
};
return (
<>
<SendouMenu
trigger={
<SendouButton
size="big"
variant="minimal"
icon={<Menu />}
aria-label={t("team:actionButtons.teamActions")}
testId="team-actions-menu-button"
/>
}
>
{showMainTeamIndicator ? (
<SendouMenuItem
icon={<Star />}
isActive
isDisabled
data-testid="main-team-indicator"
>
{t("team:actionButtons.mainTeam")}
</SendouMenuItem>
) : null}
{canMakeMainTeam ? (
<SendouMenuItem
icon={<Star />}
onAction={() => submitAction("MAKE_MAIN_TEAM")}
data-testid="make-main-team-button"
>
{t("team:actionButtons.makeMainTeam")}
</SendouMenuItem>
) : null}
{canLeaveTeam ? (
<SendouMenuItem
icon={<LogOut />}
onAction={() => setConfirming("LEAVE")}
data-testid="leave-team-button"
>
{t("team:actionButtons.leaveTeam")}
</SendouMenuItem>
) : null}
{canDeleteTeam ? (
<SendouMenuItem
icon={<Trash2 />}
isDestructive
onAction={() => setConfirming("DELETE")}
data-testid="delete-team-button"
>
{t("team:actionButtons.deleteTeam")}
</SendouMenuItem>
) : null}
</SendouMenu>
<SendouDialog
isOpen={confirming === "LEAVE"}
onClose={() => setConfirming(null)}
onOpenChange={() => setConfirming(null)}
isDismissable
>
<ConfirmActionContent
heading={t(
isTeamOwner({ user, team })
? "team:leaveTeam.header.newOwner"
: "team:leaveTeam.header",
{
teamName: team.name,
newOwner: resolveNewOwner(team.members)?.username,
},
)}
buttonText={t("team:actionButtons.leaveTeam.confirm")}
onConfirm={() => submitAction("LEAVE_TEAM")}
/>
</SendouDialog>
<SendouDialog
isOpen={confirming === "DELETE"}
onClose={() => setConfirming(null)}
onOpenChange={() => setConfirming(null)}
isDismissable
>
<ConfirmActionContent
heading={t("team:deleteTeam.header", { teamName: team.name })}
buttonText={t("common:actions.delete")}
onConfirm={() => submitAction("DELETE_TEAM")}
/>
</SendouDialog>
</>
);
}
function ConfirmActionContent({
heading,
buttonText,
onConfirm,
}: {
heading: string;
buttonText: string;
onConfirm: () => void;
}) {
return (
<div className="stack md">
<h2 className="text-md text-center">{heading}</h2>
<div className="stack horizontal md justify-center mt-2">
<SendouButton
variant="destructive"
onPress={onConfirm}
data-testid="confirm-button"
>
{buttonText}
</SendouButton>
</div>
</div>
);
}
function ResultsBanner({
results,
}: {
results: NonNullable<TeamLoaderData["results"]>;
}) {
return (
<Link className={styles.results} to="results">
<div>View {results.count} results</div>
<ul className={styles.resultsPlacements}>
{results.placements.map(({ placement, count }) => {
return (
<li key={placement}>
<Placement placement={placement} />×{count}
</li>
);
})}
</ul>
</Link>
);
}
function MemberRow({
member,
number,
}: {
member: TeamRepository.findByCustomUrl["members"][number];
number: number;
}) {
const { t } = useTranslation(["team"]);
return (
<div
className={styles.member}
data-testid={member.isOwner ? `member-owner-${member.id}` : undefined}
>
{member.customRole ? (
<span
className={styles.memberRole}
data-testid={`member-row-role-${number}`}
>
{member.customRole}
</span>
) : member.role ? (
<span
className={styles.memberRole}
data-testid={`member-row-role-${number}`}
>
{t(`team:roles.${member.role}`)}
</span>
) : null}
<div className={styles.memberSection}>
<Link
to={userPage(member)}
className={styles.memberAvatarNameContainer}
>
<div className={styles.memberAvatar}>
<Avatar user={member} size="md" />
</div>
{member.username}
</Link>
<div className="stack horizontal md">
{member.weapons.map((weapon) => (
<WeaponImage
key={weapon.weaponSplId}
weapon={weapon}
width={48}
height={48}
/>
))}
</div>
</div>
</div>
);
}
function MobileMemberCard({
member,
}: {
member: TeamRepository.findByCustomUrl["members"][number];
}) {
const { t } = useTranslation(["team"]);
return (
<div className={styles.memberCardContainer}>
<div className={styles.memberCard}>
<UserLink user={member} size="md" direction="vertical">
<div className={styles.memberCardName}>{member.username}</div>
</UserLink>
{member.weapons.length > 0 ? (
<div className="stack horizontal md">
{member.weapons.map((weapon) => (
<WeaponImage
key={weapon.weaponSplId}
weapon={weapon}
width={32}
height={32}
/>
))}
</div>
) : null}
</div>
{member.customRole ? (
<span className={styles.memberRoleMobile}>{member.customRole}</span>
) : member.role ? (
<span className={styles.memberRoleMobile}>
{t(`team:roles.${member.role}`)}
</span>
) : null}
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { useTranslation } from "react-i18next";
import { Form, useLoaderData } from "react-router";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { action } from "../actions/t.$customUrl.join.server";
import { loader } from "../loaders/t.$customUrl.join.server";
import styles from "../team.module.css";
export { action, loader };
export const handle: SendouRouteHandle = {
i18n: ["team"],
};
export default function JoinTeamPage() {
const { t } = useTranslation(["team", "common"]);
const { validation, teamName } = useLoaderData<{
// not sure why using typeof loader here results validation in being typed as "string"
validation:
| "SHORT_CODE"
| "INVITE_CODE_WRONG"
| "TEAM_FULL"
| "REACHED_TEAM_COUNT_LIMIT"
| "VALID";
teamName: string;
}>();
return (
<Main>
<Form method="post" className={styles.inviteContainer}>
<div className="text-center">
{t(`team:validation.${validation}`, { teamName })}
</div>
{validation === "VALID" ? (
<SubmitButton size="big">{t("common:actions.join")}</SubmitButton>
) : null}
</Form>
</Main>
);
}

View File

@@ -0,0 +1,22 @@
import { useLoaderData } from "react-router";
import { Main } from "~/components/Main";
import { TeamGoBackButton } from "~/features/team/components/TeamGoBackButton";
import { TeamResultsTable } from "~/features/team/components/TeamResultsTable";
import { loader } from "../loaders/t.$customUrl.results.server";
export { loader };
export const handle = {
i18n: ["user"],
};
export default function TeamResultsPage() {
const data = useLoaderData<typeof loader>();
return (
<Main className="stack lg">
<TeamGoBackButton />
<TeamResultsTable results={data.results} />
</Main>
);
}

View File

@@ -0,0 +1,182 @@
import { Check, Clipboard } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { useLoaderData } from "react-router";
import { ActionButton } from "~/components/ActionButton";
import { Alert } from "~/components/Alert";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { Main } from "~/components/Main";
import { Config } from "~/config";
import { useUser } from "~/features/auth/core/user";
import { TeamGoBackButton } from "~/features/team/components/TeamGoBackButton";
import { joinTeamPage } from "~/features/team/team-urls";
import { SendouForm } from "~/form/SendouForm";
import type { ArrayItemRenderContext } from "~/form/types";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import { metaTags } from "~/utils/remix";
import { action } from "../actions/t.$customUrl.roster.server";
import { loader } from "../loaders/t.$customUrl.roster.server";
import {
CUSTOM_ROLE_VALUE,
resetInviteLinkSchema,
updateRosterSchema,
} from "../team-schemas";
import { isTeamFull } from "../team-utils";
export { action, loader };
export const meta: MetaFunction = (args) => {
return metaTags({
title: "Managing team roster",
location: args.location,
});
};
export default function ManageTeamRosterPage() {
const { t } = useTranslation(["team"]);
return (
<Main halfWidth className="stack lg">
<TeamGoBackButton />
<InviteCodeSection />
<MemberActions />
<SendouPopover
trigger={
<SendouButton
className="self-start italic"
size="small"
variant="minimal"
>
{t("team:editorsInfo.button")}
</SendouButton>
}
>
{t("team:editorsInfo.popover")}
</SendouPopover>
</Main>
);
}
function InviteCodeSection() {
const { t } = useTranslation(["common", "team"]);
const { team } = useLoaderData<typeof loader>();
const { copyToClipboard, copySuccess } = useCopyToClipboard();
if (isTeamFull(team)) {
return (
<Alert variation="INFO" alertClassName="mx-auto w-max">
{t("team:roster.teamFull")}
</Alert>
);
}
const inviteLink = `${Config.siteDomain}${joinTeamPage({
customUrl: team.customUrl,
inviteCode: team.inviteCode!,
})}`;
return (
<div>
<h2 className="text-lg">{t("team:roster.inviteLink.header")}</h2>
<div className="stack md">
<div className="text-sm" data-testid="invite-link">
{inviteLink}
</div>
<div className="stack horizontal md">
<SendouButton
size="small"
icon={copySuccess ? <Check /> : <Clipboard />}
onPress={() => copyToClipboard(inviteLink)}
>
{t("common:actions.copyToClipboard")}
</SendouButton>
<ActionButton
schema={resetInviteLinkSchema}
action="RESET_INVITE_LINK"
variant="minimal-destructive"
size="small"
testId="reset-invite-link-button"
>
{t("common:actions.reset")}
</ActionButton>
</div>
</div>
</div>
);
}
function MemberActions() {
const { t } = useTranslation(["common", "team"]);
const { team } = useLoaderData<typeof loader>();
const user = useUser();
const isProtectedMember = (member: { id: number; isOwner: number }) =>
Boolean(member.isOwner) || member.id === user!.id;
return (
<div className="stack md">
<h2 className="text-lg">{t("team:roster.members.header")}</h2>
<SendouForm
fullWidth
schema={updateRosterSchema}
submitButtonText={t("common:actions.save")}
defaultValues={{
members: team.members.map((member) => ({
userId: member.id,
role: member.customRole ? CUSTOM_ROLE_VALUE : (member.role ?? null),
customRole: member.customRole ?? null,
roleType: member.roleType ?? "PLAYER",
isManager: Boolean(member.isManager),
})),
}}
>
{({ FormField }) => (
<FormField
name="members"
canRemoveItem={(item) => {
const member = team.members.find(
(m) => m.id === (item as { userId: number }).userId,
);
return Boolean(member) && !isProtectedMember(member!);
}}
>
{({ index, itemName, values }: ArrayItemRenderContext) => {
const member = team.members.find(
(m) => m.id === (values as { userId: number }).userId,
);
return (
<div
className="stack md-plus"
data-testid={`member-row-${index}`}
>
<div
className="stack horizontal sm items-center text-sm font-bold mb-2"
data-testid={`member-row-username-${index}`}
>
{member ? <Avatar size="xs" user={member} /> : null}
{member?.username}
</div>
<FormField name={`${itemName}.role`} />
{(values as { role: string | null }).role ===
CUSTOM_ROLE_VALUE ? (
<>
<FormField name={`${itemName}.customRole`} />
<FormField name={`${itemName}.roleType`} />
</>
) : null}
{member && !isProtectedMember(member) ? (
<FormField name={`${itemName}.isManager`} />
) : null}
</div>
);
}}
</FormField>
)}
</SendouForm>
</div>
);
}

View File

@@ -0,0 +1,171 @@
import { beforeEach, describe, expect, test } from "vitest";
import { REGULAR_USER_TEST_ID } from "~/db/seed/constants";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import { assertResponseErrored, wrappedAction } from "~/utils/Test";
import { action as _teamPageAction } from "../actions/t.$customUrl.index.server";
import { action as teamIndexPageAction } from "../actions/t.new.server";
import * as TeamRepository from "../TeamRepository.server";
import type {
createTeamSchema,
teamProfilePageActionSchema,
} from "../team-schemas";
import {
createTeamOwnedByRegular,
createTeamWithRegularMember,
} from "../tests/fixtures";
const createTeamAction = wrappedAction<typeof createTeamSchema>({
action: teamIndexPageAction,
isJsonSubmission: true,
});
const teamPageAction = wrappedAction<typeof teamProfilePageActionSchema>({
action: _teamPageAction,
isJsonSubmission: true,
});
async function loadTeams() {
const teams =
await TeamRepository.findAllByMemberUserId(REGULAR_USER_TEST_ID);
const mainTeam = teams.find((t) => t.isMainTeam);
const secondaryTeams = teams.filter((t) => !t.isMainTeam);
return { team: mainTeam, secondaryTeams };
}
describe("Secondary teams", () => {
beforeEach(async () => {
await UserFactory.createAdmin();
await UserFactory.createRegular();
});
test("first team created becomes main team", async () => {
await createTeamAction({ name: "Team 1" }, { user: "regular" });
const { team, secondaryTeams } = await loadTeams();
expect(team!.name).toBe("Team 1");
expect(secondaryTeams).toHaveLength(0);
});
test("second team created becomes secondary", async () => {
await createTeamAction({ name: "Team 1" }, { user: "regular" });
await createTeamAction({ name: "Team 2" }, { user: "regular" });
const { team, secondaryTeams } = await loadTeams();
expect(team!.name).toBe("Team 1");
expect(secondaryTeams[0].name).toBe("Team 2");
});
test("makes secondary team main team", async () => {
await createTeamAction({ name: "Team 1" }, { user: "regular" });
await createTeamAction({ name: "Team 2" }, { user: "regular" });
const { team, secondaryTeams } = await loadTeams();
expect(team!.name).toBe("Team 1");
expect(secondaryTeams[0].name).toBe("Team 2");
});
test("sets main team (2 team)", async () => {
await createTeamOwnedByRegular("Team 1");
const secondary = await createTeamOwnedByRegular("Team 2", false);
await teamPageAction(
{ _action: "MAKE_MAIN_TEAM" },
{ user: "regular", params: { customUrl: secondary.customUrl } },
);
const { team } = await loadTeams();
expect(team!.name).toBe("Team 2");
});
test("when deleting the main team, the secondary team becomes main", async () => {
const main = await createTeamOwnedByRegular("Team 1");
await createTeamOwnedByRegular("Team 2", false);
await teamPageAction(
{
_action: "DELETE_TEAM",
},
{
user: "regular",
params: { customUrl: main.customUrl },
},
);
const { team, secondaryTeams } = await loadTeams();
expect(team!.name).toBe("Team 2");
expect(secondaryTeams).toHaveLength(0);
});
test("only the team owner (or admin) can delete a team", async () => {
const { customUrl } = await createTeamWithRegularMember({ name: "Team 1" });
await expect(
teamPageAction(
{ _action: "DELETE_TEAM" },
{ user: "regular", params: { customUrl } },
),
).rejects.toThrow("Response thrown with status code: 403");
expect(await TeamRepository.findByCustomUrl(customUrl)).toBeTruthy();
});
test("when leaving the main team, the secondary team becomes main", async () => {
// owned by the admin because you can't leave a team you own
const main = await createTeamWithRegularMember({ name: "Team 1" });
await createTeamWithRegularMember({ name: "Team 2", isMainTeam: false });
const { team, secondaryTeams } = await loadTeams();
expect(team!.name).toBe("Team 1");
expect(secondaryTeams[0].name).toBe("Team 2");
await teamPageAction(
{
_action: "LEAVE_TEAM",
},
{
user: "regular",
params: { customUrl: main.customUrl },
},
);
const { team: newTeam, secondaryTeams: newSecondaryTeams } =
await loadTeams();
expect(newTeam!.name).toBe("Team 2");
expect(newSecondaryTeams).toHaveLength(0);
});
test("creates max 2 teams as non-patron", async () => {
await createTeamAction({ name: "Team 1" }, { user: "regular" });
await createTeamAction({ name: "Team 2" }, { user: "regular" });
const response = await createTeamAction(
{ name: "Team 3" },
{ user: "regular" },
);
assertResponseErrored(response);
});
});
describe("Secondary teams as patron", () => {
beforeEach(async () => {
await UserFactory.createRegular(null, { patronTier: 2 });
});
test("creates more than 2 teams as patron", async () => {
await createTeamAction({ name: "Team 1" }, { user: "regular" });
await createTeamAction({ name: "Team 2" }, { user: "regular" });
await createTeamAction({ name: "Team 3" }, { user: "regular" });
const { secondaryTeams } = await loadTeams();
expect(secondaryTeams).toHaveLength(2);
});
});

View File

@@ -0,0 +1,159 @@
import clsx from "clsx";
import type { MetaFunction } from "react-router";
import { Outlet, useLoaderData } from "react-router";
import * as R from "remeda";
import { Flag } from "~/components/Flag";
import { BskyIcon } from "~/components/icons/Bsky";
import { Main } from "~/components/Main";
import { metaTags, type SerializeFrom } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { bskyUrl, navIconUrl, TEAM_SEARCH_PAGE, teamPage } from "~/utils/urls";
import { loader } from "../loaders/t.$customUrl.server";
export { loader };
import styles from "../team.module.css";
export const meta: MetaFunction<typeof loader> = (args) => {
if (!args.loaderData) return [];
return metaTags({
title: args.loaderData.team.name,
description: args.loaderData.team.bio ?? undefined,
location: args.location,
image: args.loaderData.team.avatarUrl
? {
url: args.loaderData.team.avatarUrl,
dimensions: {
width: 124,
height: 124,
},
}
: undefined,
});
};
export const handle: SendouRouteHandle = {
i18n: ["team"],
breadcrumb: ({ match }) => {
const data = match.loaderData as SerializeFrom<typeof loader> | undefined;
if (!data) return [];
return [
{
imgPath: navIconUrl("t"),
href: TEAM_SEARCH_PAGE,
type: "IMAGE",
},
{
text: data.team.name,
href: teamPage(data.team.customUrl),
type: "TEXT",
},
];
},
};
export default function TeamPage() {
return (
<Main className="stack sm">
<div className="stack sm">
<TeamBanner />
</div>
<MobileTeamNameCountry />
<Outlet />
</Main>
);
}
function TeamBanner() {
const { team } = useLoaderData<typeof loader>();
return (
<>
<div
className={clsx(
styles.banner,
!team.bannerUrl && styles.bannerPlaceholder,
)}
style={{
"--team-banner-img": team.bannerUrl
? `url("${team.bannerUrl}")`
: undefined,
}}
>
{team.avatarUrl ? (
<div className={styles.bannerAvatar}>
<div>
<img src={team.avatarUrl} alt="" />
</div>
</div>
) : null}
<div className={styles.bannerFlags}>
{R.unique(
team.members
.map((member) => member.country)
.filter((country) => country !== null),
).map((country) => {
return <Flag key={country} countryCode={country} />;
})}
</div>
<div className={styles.bannerName}>
{team.tag ? (
<div className={`${styles.bannerTag} ${styles.bannerTagDesktop}`}>
{team.tag}
</div>
) : null}
{team.name} <BskyLink />
</div>
</div>
{team.avatarUrl ? <div className={styles.bannerAvatarSpacer} /> : null}
</>
);
}
function MobileTeamNameCountry() {
const { team } = useLoaderData<typeof loader>();
return (
<div className={styles.mobileNameCountry}>
<div className="stack horizontal sm">
{R.unique(
team.members
.map((member) => member.country)
.filter((country) => country !== null),
).map((country) => {
return <Flag key={country} countryCode={country} tiny />;
})}
</div>
<div className={styles.mobileTeamName}>
{team.name}
<BskyLink />
</div>
{team.tag ? (
<div className={`${styles.bannerTag} ${styles.bannerTagMobile}`}>
{team.tag}
</div>
) : null}
</div>
);
}
function BskyLink() {
const { team } = useLoaderData<typeof loader>();
if (!team.bsky) return null;
return (
<a
className={styles.bskyLink}
data-testid="bsky-link"
href={bskyUrl(team.bsky)}
target="_blank"
rel="noreferrer"
>
<BskyIcon />
</a>
);
}

View File

@@ -0,0 +1,61 @@
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { useLoaderData } from "react-router";
import { Alert } from "~/components/Alert";
import { Main } from "~/components/Main";
import { SendouForm } from "~/form";
import { useHasRole } from "~/modules/permissions/hooks";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { NEW_TEAM_PAGE, navIconUrl } from "~/utils/urls";
import { action } from "../actions/t.new.server";
import { loader } from "../loaders/t.new.server";
import { TEAM } from "../team-constants";
import { createTeamSchema } from "../team-schemas";
export { action, loader };
export const meta: MetaFunction = (args) => {
return metaTags({
title: "New team",
location: args.location,
});
};
export const handle: SendouRouteHandle = {
i18n: ["team", "forms"],
breadcrumb: () => ({
imgPath: navIconUrl("t"),
href: NEW_TEAM_PAGE,
type: "IMAGE",
}),
};
export default function NewTeamPage() {
const { t } = useTranslation(["team"]);
const data = useLoaderData<typeof loader>();
const isSupporter = useHasRole("SUPPORTER");
const canAddNewTeam = () => {
if (isSupporter) {
return data.teamMemberOfCount < TEAM.MAX_TEAM_COUNT_PATRON;
}
return data.teamMemberOfCount < TEAM.MAX_TEAM_COUNT_NON_PATRON;
};
return (
<Main className="stack lg half-width">
{!canAddNewTeam() ? (
<Alert variation="WARNING">
You can't add another team (max 2 for non-supporters and 5 for
supporters).
</Alert>
) : (
<SendouForm schema={createTeamSchema} title={t("team:newTeam.header")}>
{({ FormField }) => <FormField name="name" />}
</SendouForm>
)}
</Main>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "react-router";
export const loader = () => {
return redirect("/?search=open&type=teams");
};

View File

@@ -0,0 +1,36 @@
export const TEAM = {
NAME_MAX_LENGTH: 64,
NAME_MIN_LENGTH: 2,
BIO_MAX_LENGTH: 2000,
BSKY_MAX_LENGTH: 50,
TAG_MAX_LENGTH: 6,
MAX_MEMBER_COUNT: 10,
MAX_TEAM_COUNT_NON_PATRON: 2,
MAX_TEAM_COUNT_PATRON: 5,
};
export const TEAM_MEMBER_ROLES = [
"CAPTAIN",
"CO_CAPTAIN",
"FRONTLINE",
"SLAYER",
"SKIRMISHER",
"SUPPORT",
"MIDLINE",
"BACKLINE",
"FLEX",
"SUB",
"COACH",
"CHEERLEADER",
] as const;
export type MemberRole = (typeof TEAM_MEMBER_ROLES)[number];
/** Classifies how a team member's `customRole` should be treated. */
export type MemberRoleType = "PLAYER" | "OTHER";
/** Roles that are not part of a team's active competitive lineup. Excluded when sourcing a roster (e.g. prefilling tournament registration or a scrim post). */
export const NON_PLAYER_TEAM_ROLES: readonly (typeof TEAM_MEMBER_ROLES)[number][] =
["CHEERLEADER", "COACH", "SUB"];
export const CUSTOM_ROLE_MAX_LENGTH = 32;

View File

@@ -0,0 +1,28 @@
import { z } from "zod";
import { mySlugify } from "~/utils/urls";
import * as TeamRepository from "./TeamRepository.server";
import {
createTeamSchema,
resetInviteLinkSchema,
updateRosterSchema,
} from "./team-schemas";
export const createTeamSchemaServer = z.object({
...createTeamSchema.shape,
name: createTeamSchema.shape.name.refine(
async (name) => {
const teams = await TeamRepository.findAllUndisbanded();
const customUrl = mySlugify(name);
return !teams.some((team) => team.customUrl === customUrl);
},
{ message: "forms:errors.duplicateName" },
),
});
export const teamParamsSchema = z.object({ customUrl: z.string() });
export const manageRosterSchema = z.union([
updateRosterSchema,
resetInviteLinkSchema,
]);

View File

@@ -0,0 +1,166 @@
import { z } from "zod";
import type { UserMapModePreferences } from "~/db/tables-json";
import { mapModePreferencesValueSchema } from "~/features/settings/match-profile-schemas";
import {
array,
customField,
fieldset,
idConstant,
image,
select,
selectOptional,
stringConstant,
textAreaOptional,
textField,
textFieldOptional,
toggle,
} from "~/form/fields";
import { mySlugify } from "~/utils/urls";
import { _action, themeInputSchema } from "~/utils/zod";
import {
CUSTOM_ROLE_MAX_LENGTH,
TEAM,
TEAM_MEMBER_ROLES,
} from "./team-constants";
export const resetInviteLinkSchema = z.object({
_action: _action("RESET_INVITE_LINK"),
});
export const teamProfilePageActionSchema = z.union([
z.object({
_action: _action("LEAVE_TEAM"),
}),
z.object({
_action: _action("MAKE_MAIN_TEAM"),
}),
z.object({
_action: _action("DELETE_TEAM"),
}),
]);
const teamNameValidate = {
func: (teamName: string) =>
mySlugify(teamName).length > 0 && mySlugify(teamName) !== "new",
message: "forms:errors.noOnlySpecialCharacters",
} as const;
export const createTeamSchema = z.object({
name: textField({
label: "labels.name",
minLength: TEAM.NAME_MIN_LENGTH,
maxLength: TEAM.NAME_MAX_LENGTH,
validate: teamNameValidate,
}),
});
export const editTeamFormSchema = z.object({
_action: stringConstant("EDIT"),
name: textField({
label: "labels.name",
bottomText: "bottomTexts.name",
minLength: TEAM.NAME_MIN_LENGTH,
maxLength: TEAM.NAME_MAX_LENGTH,
validate: teamNameValidate,
}),
tag: textFieldOptional({
label: "labels.tag",
bottomText: "bottomTexts.tag",
maxLength: TEAM.TAG_MAX_LENGTH,
}),
bsky: textFieldOptional({
label: "labels.teamBsky",
leftAddon: "https://bsky.app/profile/",
maxLength: TEAM.BSKY_MAX_LENGTH,
}),
bio: textAreaOptional({
label: "labels.bio",
maxLength: TEAM.BIO_MAX_LENGTH,
}),
logo: image({ label: "labels.logo" }),
banner: image({ label: "labels.banner", dimensions: "thick-banner" }),
});
export const updateTeamCustomThemeSchema = z.object({
_action: _action("UPDATE_CUSTOM_THEME"),
newValue: z.preprocess(
(val) => (!val || val === "null" ? null : val),
themeInputSchema.nullable(),
),
});
export const updateTeamMapModePreferencesSchema = z.object({
_action: stringConstant("UPDATE_MAP_MODE_PREFERENCES"),
mapModePreferences: customField(
{ initialValue: { modes: [], pool: [] } satisfies UserMapModePreferences },
mapModePreferencesValueSchema,
),
});
const removeTeamMapModePreferencesSchema = z.object({
_action: _action("REMOVE_MAP_MODE_PREFERENCES"),
});
/** Every payload the team edit route action accepts, discriminated by `_action`. */
export const editTeamActionSchema = z.union([
editTeamFormSchema,
updateTeamCustomThemeSchema,
updateTeamMapModePreferencesSchema,
removeTeamMapModePreferencesSchema,
]);
/** Sentinel `role` value selected to switch a member to a free-text custom role. Never stored. */
export const CUSTOM_ROLE_VALUE = "CUSTOM";
export const updateRosterSchema = z
.object({
_action: stringConstant("UPDATE_ROSTER"),
members: array({
max: TEAM.MAX_MEMBER_COUNT,
addable: false,
sortable: true,
field: fieldset({
fields: z.object({
userId: idConstant(),
role: selectOptional({
label: "labels.teamMemberRole",
items: [
...TEAM_MEMBER_ROLES.map((role) => ({
value: role,
label: `options.teamMemberRole.${role}` as const,
})),
{
value: CUSTOM_ROLE_VALUE,
label: "options.teamMemberRole.CUSTOM" as const,
},
],
}),
customRole: textFieldOptional({
label: "labels.teamMemberCustomRole",
maxLength: CUSTOM_ROLE_MAX_LENGTH,
}),
roleType: select({
label: "labels.teamMemberRoleType",
items: [
{ value: "PLAYER", label: "options.teamMemberRoleType.PLAYER" },
{ value: "OTHER", label: "options.teamMemberRoleType.OTHER" },
],
}),
isManager: toggle({ label: "labels.teamEditor" }),
}),
}),
}),
})
.superRefine((data, ctx) => {
for (const [index, member] of data.members.entries()) {
const isCustom = member.role === CUSTOM_ROLE_VALUE;
if (isCustom && !member.customRole) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["members", index, "customRole"],
message: "forms:errors.customRoleRequired",
});
}
}
});

View File

@@ -0,0 +1,11 @@
import { describe, test } from "vitest";
import { assertRoundTrips } from "~/modules/search-params/search-params-test-utils";
import { teamJoinSearchParams } from "./team-search-params";
describe("teamJoinSearchParams", () => {
test("round-trips", () => {
assertRoundTrips(teamJoinSearchParams, {
code: ["abcd1234", "F3-9_xyz"],
});
});
});

View File

@@ -0,0 +1,7 @@
import { z } from "zod";
import * as SearchParams from "~/modules/search-params/search-params";
import { SP } from "~/modules/search-params/search-params";
export const teamJoinSearchParams = SearchParams.define({
code: SP.param(z.string().nullable(), { loader: true }),
});

View File

@@ -0,0 +1,13 @@
import { teamPage } from "~/utils/urls";
import { teamJoinSearchParams } from "./team-search-params";
export const joinTeamPage = ({
customUrl,
inviteCode,
}: {
customUrl: string;
inviteCode: string;
}) =>
teamJoinSearchParams.href(`${teamPage(customUrl)}/join`, {
code: inviteCode,
});

View File

@@ -0,0 +1,76 @@
import { describe, expect, test } from "vitest";
import { subsOfResult } from "./team-utils";
describe("subsOfResult()", () => {
test("returns empty array if all participants are current members", () => {
const result = {
participants: [{ id: 1 }, { id: 2 }],
startsAt: 1000,
};
const members = [
{ userId: 1, createdAt: 500, leftAt: null },
{ userId: 2, createdAt: 600, leftAt: null },
];
const subs = subsOfResult(result, members);
expect(subs).toEqual([]);
});
test("returns participant not in members as sub", () => {
const result = {
participants: [{ id: 1 }, { id: 2 }],
startsAt: 1000,
};
const members = [{ userId: 1, createdAt: 500, leftAt: null }];
const subs = subsOfResult(result, members);
expect(subs).toEqual([{ id: 2 }]);
});
test("returns participant as sub if they left before result startTime", () => {
const result = {
participants: [{ id: 1 }, { id: 2 }],
startsAt: 1000,
};
const members = [
{ userId: 1, createdAt: 500, leftAt: 900 },
{ userId: 2, createdAt: 600, leftAt: null },
];
const subs = subsOfResult(result, members);
expect(subs).toEqual([{ id: 1 }]);
});
test("does not return participant as sub if they were a member during result", () => {
const result = {
participants: [{ id: 1 }, { id: 2 }],
startsAt: 1000,
};
const members = [
{ userId: 1, createdAt: 500, leftAt: 2000 },
{ userId: 2, createdAt: 600, leftAt: null },
];
const subs = subsOfResult(result, members);
expect(subs).toEqual([]);
});
test("returns multiple subs correctly", () => {
const result = {
participants: [{ id: 1 }, { id: 2 }, { id: 3 }],
startsAt: 1000,
};
const members = [
{ userId: 1, createdAt: 500, leftAt: 900 },
{ userId: 2, createdAt: 600, leftAt: null },
];
const subs = subsOfResult(result, members);
expect(subs).toEqual([{ id: 1 }, { id: 3 }]);
});
test("returns empty array if no participants", () => {
const result = {
participants: [],
startsAt: 1000,
};
const members = [{ userId: 1, createdAt: 500, leftAt: null }];
const subs = subsOfResult(result, members);
expect(subs).toEqual([]);
});
});

View File

@@ -0,0 +1,116 @@
import type { Tables } from "~/db/tables";
import type {
MemberRole,
MemberRoleType,
} from "~/features/team/team-constants";
import type * as TeamRepository from "./TeamRepository.server";
import { NON_PLAYER_TEAM_ROLES, TEAM } from "./team-constants";
export function isTeamOwner({
team,
user,
}: {
team: TeamRepository.findByCustomUrl;
user?: { id: number };
}) {
if (!user) return false;
return team.members.some((member) => member.isOwner && member.id === user.id);
}
export function isTeamMember({
team,
user,
}: {
team: TeamRepository.findByCustomUrl;
user?: { id: number };
}) {
if (!user) return false;
return team.members.some((member) => member.id === user.id);
}
export function isTeamFull(team: TeamRepository.findByCustomUrl) {
return team.members.length >= TEAM.MAX_MEMBER_COUNT;
}
export function canAddCustomizedColors(team: {
members: { patronTier: number | null }[];
}) {
return team.members.some(
(member) => member.patronTier && member.patronTier >= 2,
);
}
/** Returns the user who will become the new owner after old one leaves */
export function resolveNewOwner(
members: Array<{
id: number;
username: string;
isOwner: number;
isManager: number;
}>,
) {
const managers = members.filter((m) => m.isManager && !m.isOwner);
if (managers.length > 0) {
return managers.sort((a, b) => a.id - b.id)[0];
}
const regularMembers = members.filter((m) => !m.isOwner);
if (regularMembers.length > 0) {
return regularMembers.sort((a, b) => a.id - b.id)[0];
}
return null;
}
/**
* Resolves how a team member's role should be classified. For custom roles the explicit
* `roleType` is authoritative; for predefined roles it is derived from {@link NON_PLAYER_TEAM_ROLES}.
* Returns `null` when the member has no role at all (treated as a player by callers).
*/
export function getMemberRoleType(member: {
role: MemberRole | null;
roleType: MemberRoleType | null;
}): MemberRoleType | null {
if (member.roleType) return member.roleType;
if (!member.role) return null;
return NON_PLAYER_TEAM_ROLES.includes(member.role) ? "OTHER" : "PLAYER";
}
/**
* Returns a list of participant IDs who are considered "substitutes" for a given tournament result,
* based on the team's member history and the result's participants.
*
* A participant is considered a substitute if both:
* - They are not a current member (i.e., their `leftAt` is set).
* - They are not a past member who was part of the team during the result's start time.
*/
export function subsOfResult<T extends { id: number }>(
result: { participants: Array<T>; startsAt: number },
members: Array<Pick<Tables["TeamMember"], "userId" | "createdAt" | "leftAt">>,
) {
const currentMembers = members.filter((member) => !member.leftAt);
const pastMembers = members.filter((member) => member.leftAt);
const subs = result.participants.reduce((acc: Array<T>, cur) => {
if (currentMembers.some((member) => member.userId === cur.id)) return acc;
if (
pastMembers.some(
(member) =>
member.userId === cur.id &&
member.createdAt < result.startsAt &&
member.leftAt &&
member.leftAt > result.startsAt,
)
) {
return acc;
}
acc.push(cur);
return acc;
}, []);
return subs;
}

View File

@@ -0,0 +1,293 @@
.banner {
background-image:
linear-gradient(
to bottom,
rgba(255, 255, 255, 0),
rgba(255, 255, 255, 0),
rgba(0, 0, 0, 0.6)
),
var(--team-banner-img);
background-size: cover;
width: 100%;
aspect-ratio: 2 / 1;
display: grid;
grid-template-areas: "flags ." "avatar name";
padding: var(--s-5);
border-radius: var(--radius-box);
}
.bannerPlaceholder {
height: 6rem;
background-color: var(--color-accent-low);
}
.bannerFlags {
grid-area: flags;
margin-top: -5px;
display: none;
column-gap: var(--s-4);
}
.bannerName {
grid-area: name;
align-self: flex-end;
justify-self: flex-end;
font-size: 36px;
line-height: 1;
font-weight: var(--weight-bold);
color: #fff;
display: none;
align-items: center;
gap: var(--s-3);
position: relative;
}
.bskyLink {
padding: var(--s-1);
border: 1px solid;
border-radius: 50%;
border-color: #1285fe;
background-color: #1285fe2f;
height: 24.4px;
width: 24.4px;
display: grid;
place-items: center;
& > svg {
width: 0.9rem;
}
& path {
fill: #1285fe;
}
}
.bannerAvatar {
grid-area: avatar;
align-self: flex-end;
margin-bottom: -110px;
& > div {
padding: var(--s-2);
background-color: var(--color-bg);
border-radius: var(--radius-avatar);
display: grid;
place-items: center;
width: 7rem;
}
& img {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: var(--radius-avatar);
}
}
.bannerAvatarSpacer {
height: 4rem;
}
.mobileNameCountry {
display: flex;
font-size: var(--font-xl);
align-items: center;
font-weight: var(--weight-bold);
flex-direction: column;
}
.mobileTeamName {
display: flex;
align-items: center;
gap: var(--s-2);
}
.bannerTag {
font-size: var(--font-sm);
background-color: var(--color-accent-low);
color: var(--color-accent);
padding: var(--s-1) var(--s-1-5);
border-radius: var(--radius-box);
}
.bannerTagDesktop {
position: absolute;
bottom: 41px;
right: 0;
display: none;
}
.bannerTagMobile {
font-size: var(--font-xs);
padding: var(--s-0-5) var(--s-1);
margin-block: var(--s-1);
}
.actionButtons {
display: flex;
justify-content: center;
align-items: center;
gap: var(--s-2);
flex-wrap: wrap;
}
.results {
background-color: var(--color-bg-higher);
max-width: 32rem;
margin: 0 auto;
border-radius: var(--radius-box);
padding: var(--s-1) var(--s-4);
font-weight: var(--weight-bold);
font-size: var(--font-sm);
display: flex;
justify-content: space-between;
width: min(100%, 48rem);
color: var(--color-text);
white-space: nowrap;
}
.resultsPlacements {
list-style: none;
display: flex;
gap: var(--s-4);
& > li {
display: flex;
align-items: center;
gap: var(--s-1);
}
}
.member {
display: none;
flex-direction: column;
}
.memberSection {
background-color: var(--color-bg-high);
border-radius: var(--radius-box);
padding: var(--s-2) var(--s-4);
font-size: var(--font-xl);
font-weight: var(--weight-bold);
display: flex;
justify-content: space-between;
align-items: center;
height: 4.5rem;
}
.memberAvatarNameContainer {
display: flex;
align-items: center;
gap: var(--s-4);
color: var(--color-text);
font-weight: var(--weight-bold);
}
.memberAvatar {
background-color: var(--color-bg);
padding: var(--s-2);
border-radius: var(--radius-avatar);
}
.memberRole {
margin-left: auto;
font-size: var(--font-sm);
color: var(--color-text-high);
margin-inline-end: var(--s-3);
font-weight: var(--weight-bold);
}
.memberRoleMobile {
color: var(--color-text-high);
font-weight: var(--weight-bold);
}
.memberCardContainer {
width: 16rem;
margin: 0 auto;
display: flex;
text-align: center;
flex-direction: column;
}
.memberCard {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-2);
padding: var(--s-3);
background-color: var(--color-bg-high);
border-radius: var(--radius-box);
font-size: var(--font-lg);
font-weight: var(--weight-bold);
}
.memberCardName {
font-weight: var(--weight-bold);
margin-block-start: var(--s-0-5);
}
.inviteContainer {
margin-block-start: var(--s-16);
display: flex;
flex-direction: column;
gap: var(--s-12);
align-items: center;
}
.imageLinksList {
display: flex;
gap: var(--s-8);
padding-left: var(--s-4);
font-size: var(--font-sm);
}
.formDivider {
margin-block: var(--s-6);
}
.mapPreferencesDivider {
margin-block-start: var(--s-6);
margin-block-end: var(--s-2);
}
@container (width >= 640px) {
.bannerFlags {
display: flex;
}
.bannerName {
display: flex;
}
.bannerTagDesktop {
display: initial;
}
.bannerAvatar > div {
width: 10rem;
}
.bannerAvatar {
margin-left: var(--s-2);
margin-bottom: -90px;
}
.mobileNameCountry {
display: none;
}
.member {
display: flex;
}
.memberCardContainer {
display: none;
}
.bannerPlaceholder {
height: 12rem;
}
.actionButtons {
justify-content: flex-end;
}
}

View File

@@ -0,0 +1,20 @@
import { REGULAR_USER_TEST_ID } from "~/db/seed/constants";
import * as TeamFactory from "~/db/seed/factories/TeamFactory";
import { ADMIN_ID } from "~/features/admin/admin-constants";
/** A team the regular user owns, so acting as "regular" exercises the owner paths. */
export const createTeamOwnedByRegular = (name: string, isMainTeam = true) =>
TeamFactory.create({
name,
isMainTeam,
memberUserIds: [REGULAR_USER_TEST_ID],
});
/** A team the regular user is a member but not the owner of. */
export const createTeamWithRegularMember = (
overrides: Partial<Parameters<typeof TeamFactory.create>[0]> = {},
) =>
TeamFactory.create({
memberUserIds: [ADMIN_ID, REGULAR_USER_TEST_ID],
...overrides,
});