sendou.ink/app/features/sendouq/routes/q.tsx
2026-03-21 15:19:32 +02:00

468 lines
11 KiB
TypeScript

import clsx from "clsx";
import { User, Users } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { Link, useFetcher, useLoaderData } from "react-router";
import { Alert } from "~/components/Alert";
import { LinkButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { Flag } from "~/components/Flag";
import { FormMessage } from "~/components/FormMessage";
import { FriendCodePopover } from "~/components/FriendCodePopover";
import { Image } from "~/components/Image";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import type { Tables } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import type * as Seasons from "~/features/mmr/core/Seasons";
import { useAutoRerender } from "~/hooks/useAutoRerender";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHasRole } from "~/modules/permissions/hooks";
import { metaTags, type SerializeFrom } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
LEADERBOARDS_PAGE,
LOG_IN_URL,
navIconUrl,
SENDOUQ_INFO_PAGE,
SENDOUQ_LOOKING_PREVIEW_PAGE,
SENDOUQ_PAGE,
SENDOUQ_RULES_PAGE,
SENDOUQ_SETTINGS_PAGE,
SENDOUQ_STREAMS_PAGE,
userSeasonsPage,
} from "~/utils/urls";
import { SendouButton } from "../../../components/elements/Button";
import { SendouPopover } from "../../../components/elements/Popover";
import { action } from "../actions/q.server";
import { loader } from "../loaders/q.server";
import { FULL_GROUP_SIZE } from "../q-constants";
import { userCanJoinQueueAt } from "../q-utils";
export { action, loader };
import styles from "./q.module.css";
export const handle: SendouRouteHandle = {
i18n: ["q"],
breadcrumb: () => ({
imgPath: navIconUrl("sendouq"),
href: SENDOUQ_PAGE,
type: "IMAGE",
}),
};
export const meta: MetaFunction = (args) => {
return metaTags({
title: "SendouQ",
description:
"Splatoon 3 competitive ladder. Join by yourself or with your team and play ranked matches.",
location: args.location,
});
};
export default function QPage() {
const { t } = useTranslation(["q"]);
const [dialogOpen, setDialogOpen] = React.useState(true);
const user = useUser();
const data = useLoaderData<typeof loader>();
const fetcher = useFetcher();
const queueJoinStatus =
user && data.friendCode ? userCanJoinQueueAt(user, data.friendCode) : null;
return (
<Main halfWidth className="stack lg">
<div className="stack md">
{data.season ? (
<ActiveSeasonInfo season={data.season} />
) : data.upcomingSeason ? (
<UpcomingSeasonInfo season={data.upcomingSeason} />
) : (
<NoUpcomingSeasonInfo />
)}
<Clocks />
</div>
{data.season ? (
<>
{data.groupInvitedTo === null ? (
<Alert variation="WARNING">{t("q:front.inviteCodeWrong")}</Alert>
) : null}
{!data.friendCode &&
data.groupInvitedTo &&
data.groupInvitedTo.members.length < FULL_GROUP_SIZE ? (
<Alert variation="WARNING">{t("q:front.noFriendCode")}</Alert>
) : null}
{queueJoinStatus === "NOW" &&
data.groupInvitedTo &&
data.groupInvitedTo.members.length < FULL_GROUP_SIZE ? (
<JoinTeamDialog
open={dialogOpen}
close={() => setDialogOpen(false)}
members={data.groupInvitedTo.members}
/>
) : null}
{user?.friendCode ? (
<fetcher.Form className="stack md" method="post">
<input type="hidden" name="_action" value="JOIN_QUEUE" />
<div className="stack horizontal md items-center mt-4 mx-auto">
<SubmitButton
icon={<Users />}
isDisabled={queueJoinStatus !== "NOW"}
>
{t("q:front.actions.joinWithGroup")}
</SubmitButton>
<SubmitButton
name="direct"
value="true"
state={fetcher.state}
icon={<User />}
variant="outlined"
isDisabled={queueJoinStatus !== "NOW"}
testId="join-solo-button"
>
{t("q:front.actions.joinSolo")}
</SubmitButton>
</div>
{queueJoinStatus instanceof Date ? (
<div
className="text-lighter text-xs text-center text-warning"
suppressHydrationWarning
>
As a fresh account please wait before joining the queue. You
can join{" "}
{queueJoinStatus.toLocaleString("en-US", {
day: "numeric",
month: "long",
hour: "numeric",
minute: "numeric",
})}
</div>
) : (
<PreviewQueueButton />
)}
</fetcher.Form>
) : user ? (
<div className="stack md items-center">
<FriendCodePopover />
<div className="text-lighter text-xs text-center">
{t("q:front.noFriendCodeHelp")}
</div>
</div>
) : (
<form
className="stack md items-center"
action={LOG_IN_URL}
method="post"
>
<SendouButton size="big" type="submit">
{t("q:front.actions.logIn")}
</SendouButton>
</form>
)}
</>
) : null}
{user?.friendCode ? (
<div className="stack items-center">
<FriendCodePopover size="small" />
</div>
) : null}
<QLinks />
</Main>
);
}
const countries = [
{
id: 1,
countryCode: "US",
timeZone: "America/Los_Angeles",
city: "la",
},
{ id: 2, countryCode: "US", timeZone: "America/New_York", city: "nyc" },
{ id: 3, countryCode: "FR", timeZone: "Europe/Paris", city: "paris" },
{ id: 4, countryCode: "JP", timeZone: "Asia/Tokyo", city: "tokyo" },
] as const;
const weekdayFormatter = ({
timeZone,
locale,
}: {
timeZone: string;
locale: string;
}) =>
new Intl.DateTimeFormat([locale], {
timeZone,
weekday: "long",
});
const clockFormatter = ({
timeZone,
locale,
}: {
timeZone: string;
locale: string;
}) =>
new Intl.DateTimeFormat([locale], {
timeZone,
hour: "numeric",
minute: "numeric",
});
function Clocks() {
const isMounted = useIsMounted();
const { t, i18n } = useTranslation(["q"]);
useAutoRerender();
return (
<div className={styles.clocksContainer}>
{countries.map((country) => {
return (
<div key={country.id} className={styles.clock}>
<div className={styles.clockCountry}>
{t(`q:front.cities.${country.city}`)}
</div>
<Flag countryCode={country.countryCode} />
<div className={clsx({ invisible: !isMounted })}>
{isMounted
? weekdayFormatter({
timeZone: country.timeZone,
locale: i18n.language,
}).format(new Date())
: // take space
"Monday"}
</div>
<div className={clsx({ invisible: !isMounted })}>
{isMounted
? clockFormatter({
timeZone: country.timeZone,
locale: i18n.language,
}).format(new Date())
: // take space
"0:00 PM"}
</div>
</div>
);
})}
</div>
);
}
function JoinTeamDialog({
open,
close,
members,
}: {
open: boolean;
close: () => void;
members: {
username: string;
role: Tables["GroupMember"]["role"];
}[];
}) {
const { t, i18n } = useTranslation(["q"]);
const fetcher = useFetcher();
return (
<SendouDialog
isOpen={open}
onClose={close}
isDismissable
className="text-center"
heading={t("q:front.join.header", {
members: new Intl.ListFormat(i18n.language).format(
members.map((m) => m.username),
),
})}
>
<fetcher.Form
className="stack horizontal justify-center md mt-6 flex-wrap"
method="post"
>
<SubmitButton _action="JOIN_TEAM" state={fetcher.state}>
{t("q:front.join.joinAction")}
</SubmitButton>
<FormMessage type="info">
{t("q:front.join.friendSuggestion")}
</FormMessage>
</fetcher.Form>
</SendouDialog>
);
}
function ActiveSeasonInfo({
season,
}: {
season: SerializeFrom<Seasons.ListItem>;
}) {
const { t, i18n } = useTranslation(["q"]);
const isMounted = useIsMounted();
const starts = new Date(season.starts);
const ends = new Date(season.ends);
const dateToString = (date: Date) =>
date.toLocaleString(i18n.language, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
});
return (
<div
className={clsx("text-lighter text-xs text-center", {
invisible: !isMounted,
})}
>
{t("q:front.seasonOpen", { nth: season.nth })}{" "}
{isMounted ? (
<b>
{dateToString(starts)} - {dateToString(ends)}
</b>
) : null}
</div>
);
}
function QLinks() {
const { t } = useTranslation(["q"]);
const user = useUser();
return (
<div className="stack sm">
<QLink
navIcon="articles"
url={SENDOUQ_INFO_PAGE}
title={t("q:front.nav.info.title")}
subText={t("q:front.nav.info.description")}
/>
{user ? (
<QLink
navIcon="settings"
url={SENDOUQ_SETTINGS_PAGE}
title={t("q:front.nav.settings.title")}
subText={t("q:front.nav.settings.description")}
/>
) : null}
<QLink
navIcon="vods"
url={SENDOUQ_STREAMS_PAGE}
title={t("q:front.nav.streams.title")}
subText={t("q:front.nav.streams.description")}
/>
<QLink
navIcon="leaderboards"
url={LEADERBOARDS_PAGE}
title={t("q:front.nav.leaderboards.title")}
subText={t("q:front.nav.leaderboards.description")}
/>
{user ? (
<QLink
navIcon="u"
url={userSeasonsPage({ user })}
title={t("q:front.nav.mySeason.title")}
subText={t("q:front.nav.mySeason.description")}
/>
) : null}
<QLink
navIcon="articles"
url={SENDOUQ_RULES_PAGE}
title={t("q:front.nav.rules.title")}
subText={t("q:front.nav.rules.description")}
/>
</div>
);
}
function QLink({
url,
navIcon,
title,
subText,
}: {
url: string;
navIcon: string;
title: string;
subText: string;
}) {
return (
<Link to={url} className={styles.frontPageLink}>
<Image path={navIconUrl(navIcon)} alt="" width={32} />
<div>
{title}
<div className={styles.linkSubText}>{subText}</div>
</div>
</Link>
);
}
function UpcomingSeasonInfo({
season,
}: {
season: SerializeFrom<Seasons.ListItem>;
}) {
const { t } = useTranslation(["q"]);
const isMounted = useIsMounted();
if (!isMounted) return null;
const starts = new Date(season.starts);
const dateToString = (date: Date) =>
date.toLocaleString("en-US", {
month: "long",
day: "numeric",
hour: "numeric",
});
return (
<div className="font-semi-bold text-center text-sm">
{t("q:front.upcomingSeason.header")}
<br />
{t("q:front.upcomingSeason.date", {
nth: season.nth,
date: dateToString(starts),
})}
</div>
);
}
function NoUpcomingSeasonInfo() {
const { t } = useTranslation(["q"]);
return (
<div className="font-semi-bold text-center text-sm">
{t("q:front.upcomingSeason.header")}
<br />
{t("q:front.noUpcomingSeason")}
</div>
);
}
function PreviewQueueButton() {
const isSupporter = useHasRole("SUPPORTER");
const { t } = useTranslation(["q"]);
if (!isSupporter) {
return (
<SendouPopover
trigger={
<SendouButton className="mx-auto text-xs" variant="minimal">
{t("q:front.preview")}
</SendouButton>
}
>
{t("q:front.preview.explanation")}
</SendouPopover>
);
}
return (
<LinkButton
to={SENDOUQ_LOOKING_PREVIEW_PAGE}
variant="minimal"
size="small"
>
{t("q:front.preview")}
</LinkButton>
);
}