sendou.ink/app/features/user-page/user-page-schemas.ts
Kalle 77978c450f
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Tests and checks on push / run-checks-and-tests (push) Waiting to run
Updates translation progress / update-translation-progress-issue (push) Waiting to run
New user page (#2812)
Co-authored-by: hfcRed <hfcred@gmx.net>
2026-02-16 19:26:57 +02:00

355 lines
8.2 KiB
TypeScript

import { z } from "zod";
import { OBJECT_PRONOUNS, SUBJECT_PRONOUNS } from "~/db/tables";
import { BADGE } from "~/features/badges/badges-constants";
import * as Seasons from "~/features/mmr/core/Seasons";
import {
checkboxGroup,
customField,
idConstantOptional,
stringConstant,
textAreaOptional,
textAreaRequired,
textFieldRequired,
toggle,
weaponPool,
} from "~/form/fields";
import {
clothesGearIds,
headGearIds,
shoesGearIds,
} from "~/modules/in-game-lists/gear-ids";
import { isCustomUrl } from "~/utils/urls";
import {
_action,
actualNumber,
checkboxValueToDbBoolean,
clothesMainSlotAbility,
customCssVarObject,
dbBoolean,
emptyArrayToNull,
falsyToNull,
headMainSlotAbility,
id,
nullLiteraltoNull,
processMany,
safeJSONParse,
safeNullableStringSchema,
shoesMainSlotAbility,
stackableAbility,
undefinedToNull,
weaponSplId,
} from "~/utils/zod";
import { allWidgetsFlat, findWidgetById } from "./core/widgets/portfolio";
import {
COUNTRY_CODES,
HIGHLIGHT_CHECKBOX_NAME,
HIGHLIGHT_TOURNAMENT_CHECKBOX_NAME,
USER,
} from "./user-page-constants";
export const userParamsSchema = z.object({ identifier: z.string() });
export const seasonsSearchParamsSchema = z.object({
page: z.coerce.number().optional(),
info: z.enum(["weapons", "stages", "mates", "enemies"]).optional(),
season: z.coerce
.number()
.optional()
.refine((nth) => !nth || Seasons.allStarted(new Date()).includes(nth)),
});
export const userEditActionSchema = z
.object({
country: z.preprocess(
falsyToNull,
z
.string()
.refine((val) => !val || COUNTRY_CODES.includes(val as any))
.nullable(),
),
bio: z.preprocess(
falsyToNull,
z.string().max(USER.BIO_MAX_LENGTH).nullable(),
),
customUrl: z.preprocess(
falsyToNull,
z
.string()
.max(USER.CUSTOM_URL_MAX_LENGTH)
.refine((val) => val === null || isCustomUrl(val), {
message: "forms.errors.invalidCustomUrl.numbers",
})
.refine((val) => val === null || /^[a-zA-Z0-9-_]+$/.test(val), {
message: "forms.errors.invalidCustomUrl.strangeCharacter",
})
.transform((val) => val?.toLowerCase())
.nullable(),
),
customName: safeNullableStringSchema({ max: USER.CUSTOM_NAME_MAX_LENGTH }),
battlefy: z.preprocess(
falsyToNull,
z.string().max(USER.BATTLEFY_MAX_LENGTH).nullable(),
),
stickSens: z.preprocess(
processMany(actualNumber, undefinedToNull),
z
.number()
.min(-50)
.max(50)
.refine((val) => val % 5 === 0)
.nullable(),
),
motionSens: z.preprocess(
processMany(actualNumber, undefinedToNull),
z
.number()
.min(-50)
.max(50)
.refine((val) => val % 5 === 0)
.nullable(),
),
subjectPronoun: z.preprocess(
processMany(nullLiteraltoNull, falsyToNull),
z.enum(SUBJECT_PRONOUNS).nullable(),
),
objectPronoun: z.preprocess(
processMany(nullLiteraltoNull, falsyToNull),
z.enum(OBJECT_PRONOUNS).nullable(),
),
inGameNameText: z.preprocess(
falsyToNull,
z.string().max(USER.IN_GAME_NAME_TEXT_MAX_LENGTH).nullable(),
),
inGameNameDiscriminator: z.preprocess(
falsyToNull,
z
.string()
.refine((val) => /^[0-9a-z]{4,5}$/.test(val))
.nullable(),
),
css: customCssVarObject,
weapons: z.preprocess(
safeJSONParse,
z
.array(
z.object({
weaponSplId,
isFavorite: dbBoolean,
}),
)
.max(USER.WEAPON_POOL_MAX_SIZE),
),
favoriteBadgeIds: z.preprocess(
processMany(safeJSONParse, emptyArrayToNull),
z
.array(id)
.min(1)
.max(BADGE.SMALL_BADGES_PER_DISPLAY_PAGE + 1)
.nullish(),
),
showDiscordUniqueName: z.preprocess(checkboxValueToDbBoolean, dbBoolean),
newProfileEnabled: z.preprocess(checkboxValueToDbBoolean, dbBoolean),
commissionsOpen: z.preprocess(checkboxValueToDbBoolean, dbBoolean),
commissionText: z.preprocess(
falsyToNull,
z.string().max(USER.COMMISSION_TEXT_MAX_LENGTH).nullable(),
),
})
.refine(
(val) => {
if (val.motionSens !== null && val.stickSens === null) {
return false;
}
return true;
},
{
message: "forms.errors.invalidSens",
},
);
export const editHighlightsActionSchema = z.object({
[HIGHLIGHT_CHECKBOX_NAME]: z.optional(
z.union([z.array(z.string()), z.string()]),
),
[HIGHLIGHT_TOURNAMENT_CHECKBOX_NAME]: z.optional(
z.union([z.array(z.string()), z.string()]),
),
});
export const addModNoteSchema = z.object({
_action: stringConstant("ADD_MOD_NOTE"),
value: textAreaRequired({
label: "labels.text",
bottomText: "bottomTexts.modNote",
maxLength: USER.MOD_NOTE_MAX_LENGTH,
}),
});
const deleteModNoteSchema = z.object({
_action: _action("DELETE_MOD_NOTE"),
noteId: id,
});
export const adminTabActionSchema = z.union([
addModNoteSchema,
deleteModNoteSchema,
]);
export const userResultsPageSearchParamsSchema = z.object({
all: z.stringbool().catch(false),
page: z.coerce.number().min(1).max(1_000).catch(1),
});
const widgetSettingsSchemas = allWidgetsFlat().map((widget) => {
if ("schema" in widget) {
return z.object({
id: z.literal(widget.id),
settings: widget.schema,
});
}
return z.object({
id: z.literal(widget.id),
});
});
const widgetSettingsSchema = z.union(widgetSettingsSchemas);
export const widgetsEditSchema = z.object({
widgets: z.preprocess(
safeJSONParse,
z
.array(widgetSettingsSchema)
.max(USER.MAX_MAIN_WIDGETS + USER.MAX_SIDE_WIDGETS)
.refine((widgets) => {
let mainCount = 0;
let sideCount = 0;
for (const w of widgets) {
const def = findWidgetById(w.id);
if (!def) return false;
if (def.slot === "main") mainCount++;
else sideCount++;
}
return (
mainCount <= USER.MAX_MAIN_WIDGETS &&
sideCount <= USER.MAX_SIDE_WIDGETS
);
}),
),
});
const headGearIdSchema = z
.number()
.nullable()
.refine(
(val) =>
val === null || headGearIds.includes(val as (typeof headGearIds)[number]),
);
const clothesGearIdSchema = z
.number()
.nullable()
.refine(
(val) =>
val === null ||
clothesGearIds.includes(val as (typeof clothesGearIds)[number]),
);
const shoesGearIdSchema = z
.number()
.nullable()
.refine(
(val) =>
val === null ||
shoesGearIds.includes(val as (typeof shoesGearIds)[number]),
);
const abilitiesSchema = z.tuple([
z.tuple([
headMainSlotAbility,
stackableAbility,
stackableAbility,
stackableAbility,
]),
z.tuple([
clothesMainSlotAbility,
stackableAbility,
stackableAbility,
stackableAbility,
]),
z.tuple([
shoesMainSlotAbility,
stackableAbility,
stackableAbility,
stackableAbility,
]),
]);
const modeItems = [
{ label: "modes.TW" as const, value: "TW" as const },
{ label: "modes.SZ" as const, value: "SZ" as const },
{ label: "modes.TC" as const, value: "TC" as const },
{ label: "modes.RM" as const, value: "RM" as const },
{ label: "modes.CB" as const, value: "CB" as const },
];
export const newBuildBaseSchema = z.object({
buildToEditId: idConstantOptional(),
weapons: weaponPool({
label: "labels.buildWeapons",
minCount: 1,
maxCount: 5,
disableSorting: true,
disableFavorites: true,
}),
head: customField({ initialValue: null }, headGearIdSchema),
clothes: customField({ initialValue: null }, clothesGearIdSchema),
shoes: customField({ initialValue: null }, shoesGearIdSchema),
abilities: customField(
{
initialValue: [
["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"],
["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"],
["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"],
],
},
abilitiesSchema,
),
title: textFieldRequired({
label: "labels.buildTitle",
maxLength: 50,
}),
description: textAreaOptional({
label: "labels.description",
maxLength: 280,
}),
modes: checkboxGroup({
label: "labels.buildModes",
items: modeItems,
}),
private: toggle({
label: "labels.buildPrivate",
bottomText: "bottomTexts.buildPrivate",
}),
});
function validateGearAllOrNone(data: {
head: number | null;
clothes: number | null;
shoes: number | null;
}) {
const gearFilled = [data.head, data.clothes, data.shoes].filter(
(g) => g !== null,
);
return gearFilled.length === 0 || gearFilled.length === 3;
}
export const gearAllOrNoneRefine = {
fn: validateGearAllOrNone,
opts: { message: "forms:errors.gearAllOrNone", path: ["head"] },
};
export const newBuildSchema = newBuildBaseSchema.refine(
gearAllOrNoneRefine.fn,
gearAllOrNoneRefine.opts,
);