mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
468 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|