363 lines
10 KiB
TypeScript
363 lines
10 KiB
TypeScript
import { redirect } from "next/navigation";
|
|
import { getFormatter, getTranslations } from "next-intl/server";
|
|
import { claimReferral } from "@/actions/referral";
|
|
import Link from "@/components/link";
|
|
import {
|
|
CurrencyIcon,
|
|
type CurrencyKind,
|
|
} from "@/components/shared/currency-icon";
|
|
import { ProfileImage } from "@/components/shared/profile-image";
|
|
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
|
import { SurfaceCard } from "@/components/surface-card";
|
|
import { auth } from "@/lib/auth";
|
|
import { avatarImageUrl } from "@/lib/format";
|
|
import { loadUserDashboard } from "@/lib/services/user-dashboard";
|
|
import CopyReferralButton from "./CopyReferralButton";
|
|
import styles from "./dashboard.module.css";
|
|
|
|
export async function generateMetadata() {
|
|
const t = await getTranslations("pages.myDashboard");
|
|
return { title: t("title") };
|
|
}
|
|
|
|
export default async function MePage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<{ claimed?: string; error?: string }>;
|
|
}) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) redirect("/login");
|
|
const userId = Number(session.user.id);
|
|
if (!Number.isFinite(userId) || userId <= 0) redirect("/login");
|
|
const [t, format, { claimed, error }] = await Promise.all([
|
|
getTranslations("pages.myDashboard"),
|
|
getFormatter(),
|
|
searchParams,
|
|
]);
|
|
let data: Awaited<ReturnType<typeof loadUserDashboard>>;
|
|
try {
|
|
data = await loadUserDashboard(userId);
|
|
} catch {
|
|
return (
|
|
<main>
|
|
<SurfaceCard className="p-6">
|
|
<p role="alert">{t("loadError")}</p>
|
|
<Link href="/me" className="btn btn-outline">
|
|
{t("retry")}
|
|
</Link>
|
|
</SurfaceCard>
|
|
</main>
|
|
);
|
|
}
|
|
const user = data.userRows[0];
|
|
if (!user)
|
|
return (
|
|
<main>
|
|
<SurfaceCard className="p-6">
|
|
<p role="alert">{t("loadError")}</p>
|
|
<Link href="/login" className="btn btn-outline">
|
|
{t("login")}
|
|
</Link>
|
|
</SurfaceCard>
|
|
</main>
|
|
);
|
|
const {
|
|
hotelName,
|
|
neededRaw,
|
|
rewardAmountRaw,
|
|
currencySettings,
|
|
alertRaw,
|
|
recentRooms,
|
|
badges,
|
|
userSettingsRows,
|
|
friendCountRows,
|
|
unreadCountRows,
|
|
referralsRows,
|
|
friends,
|
|
currencyRows,
|
|
} = data;
|
|
const userSettings = userSettingsRows[0];
|
|
const friendCount = Number(friendCountRows[0]?.value ?? 0);
|
|
const unreadCount = Number(unreadCountRows[0]?.value ?? 0);
|
|
const needed = Math.max(1, Number.parseInt(neededRaw ?? "5", 10) || 5);
|
|
const rewardAmount = Number.parseInt(rewardAmountRaw ?? "30", 10) || 0;
|
|
const rewardCurrency = (
|
|
currencySettings.referral_reward_currency_type ??
|
|
currencySettings.referral_reward_currency ??
|
|
"diamonds"
|
|
)
|
|
.trim()
|
|
.toLowerCase();
|
|
const rewardKind = ["credits", "duckets", "diamonds", "points"].includes(
|
|
rewardCurrency,
|
|
)
|
|
? (rewardCurrency as CurrencyKind)
|
|
: null;
|
|
const referralTotal = Math.max(
|
|
0,
|
|
Number(referralsRows[0]?.referralsTotal ?? 0),
|
|
);
|
|
const canClaim = referralTotal >= needed;
|
|
const remaining = Math.max(0, needed - referralTotal);
|
|
const onlineFriends = [
|
|
...new Map(
|
|
friends
|
|
.filter((friend) => friend.online === "1")
|
|
.map((friend) => [friend.id, friend]),
|
|
).values(),
|
|
];
|
|
const date = (value: number) =>
|
|
value
|
|
? format.dateTime(new Date(value * 1000), { dateStyle: "medium" })
|
|
: t("never");
|
|
const referralLink = `/register?ref=${encodeURIComponent(user.username)}`;
|
|
const avatarFull = avatarImageUrl(user.look, { direction: 2 });
|
|
const wallet = [
|
|
{ kind: "credits", value: user.credits },
|
|
{
|
|
kind: "duckets",
|
|
value:
|
|
currencyRows === null
|
|
? null
|
|
: (currencyRows.find((row) => row.type === 0)?.amount ?? 0),
|
|
},
|
|
{
|
|
kind: "diamonds",
|
|
value:
|
|
currencyRows === null
|
|
? null
|
|
: (currencyRows.find((row) => row.type === 5)?.amount ?? 0),
|
|
},
|
|
] as const;
|
|
const errorKey =
|
|
error &&
|
|
["not_enough", "no_referrals", "bad_config", "ratelimit"].includes(error)
|
|
? error
|
|
: "error";
|
|
return (
|
|
<main className={styles.dashboard}>
|
|
{claimed && (
|
|
<p role="status" className={styles.feedback}>
|
|
{t("claimed")}
|
|
</p>
|
|
)}
|
|
{error && (
|
|
<p role="alert" className={styles.feedback}>
|
|
{t(`errors.${errorKey}`)}
|
|
</p>
|
|
)}
|
|
<SurfaceCard className={styles.hero}>
|
|
<ProfileImage
|
|
src={avatarFull}
|
|
alt={user.username}
|
|
width={100}
|
|
height={140}
|
|
className={styles.avatar}
|
|
/>
|
|
<div className={styles.identity}>
|
|
<p className="muted">{t("welcome", { hotel: hotelName })}</p>
|
|
<h1>{user.username}</h1>
|
|
<p>{user.motto || t("noMotto")}</p>
|
|
<p className={styles.dates}>
|
|
{t("registered", { date: date(user.accountCreated) })} ·{" "}
|
|
{t("lastOnline", { date: date(user.lastOnline) })}
|
|
</p>
|
|
</div>
|
|
<Link href="/client" className="btn btn-primary">
|
|
{t("enter", { hotel: hotelName })} →
|
|
</Link>
|
|
</SurfaceCard>
|
|
<nav aria-label={t("quickLinks")} className={styles.shortcuts}>
|
|
{[
|
|
{ href: "/messages", label: t("messages"), count: unreadCount },
|
|
{
|
|
href: `/u/${encodeURIComponent(user.username)}`,
|
|
label: t("profile"),
|
|
count: 0,
|
|
},
|
|
{ href: "/settings", label: t("settings"), count: 0 },
|
|
{ href: "/shop", label: t("shop"), count: 0 },
|
|
].map((link) => (
|
|
<Link key={link.href} href={link.href} className={styles.shortcut}>
|
|
{link.label}
|
|
{link.count > 0 && (
|
|
<span
|
|
className={styles.count}
|
|
title={t("unread", { count: link.count })}
|
|
>
|
|
{link.count > 99 ? "99+" : link.count}
|
|
</span>
|
|
)}
|
|
</Link>
|
|
))}
|
|
</nav>
|
|
{alertRaw && <p className={styles.feedback}>{alertRaw}</p>}
|
|
<section aria-label={t("wallet")} className={styles.wallet}>
|
|
{wallet.map((currency) => (
|
|
<SurfaceCard key={currency.kind} className={styles.currency}>
|
|
<CurrencyIcon kind={currency.kind} size={28} alt="" />
|
|
<div>
|
|
<strong>
|
|
{currency.value === null ? "—" : format.number(currency.value)}
|
|
</strong>
|
|
<span>{t(`currencies.${currency.kind}`)}</span>
|
|
</div>
|
|
</SurfaceCard>
|
|
))}
|
|
</section>
|
|
<div className={styles.columns}>
|
|
<div className={styles.stack}>
|
|
<SurfaceCard
|
|
title={t("onlineFriends")}
|
|
actionHref="/friends"
|
|
actionLabel={t("allFriends")}
|
|
bodyClassName="p-5"
|
|
>
|
|
{onlineFriends.length === 0 ? (
|
|
<p className="muted">{t("noOnlineFriends")}</p>
|
|
) : (
|
|
<div className={styles.friends}>
|
|
{onlineFriends.map((friend) => (
|
|
<Link
|
|
key={friend.id}
|
|
href={`/u/${encodeURIComponent(friend.username)}`}
|
|
className={styles.friend}
|
|
>
|
|
<UserAvatarThumbnail figure={friend.look} alt="" />
|
|
<div>
|
|
<strong>{friend.username}</strong>
|
|
<span className="muted">
|
|
{friend.motto || t("noMotto")}
|
|
</span>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</SurfaceCard>
|
|
<SurfaceCard
|
|
title={t("recentRooms")}
|
|
actionHref="/community"
|
|
actionLabel={t("community")}
|
|
bodyClassName="p-5"
|
|
>
|
|
<p className="muted">{t("roomsHint")}</p>
|
|
{recentRooms.length === 0 ? (
|
|
<p className="muted">{t("noRooms")}</p>
|
|
) : (
|
|
<div className={styles.rooms}>
|
|
{recentRooms.map((room) => (
|
|
<Link
|
|
key={room.id}
|
|
href={`/room/${room.id}`}
|
|
className={styles.room}
|
|
>
|
|
<strong>{room.name}</strong>
|
|
<span className="muted">
|
|
{room.ownerName} · {room.users}/{room.usersMax}
|
|
</span>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</SurfaceCard>
|
|
<SurfaceCard title={t("activity")} bodyClassName="p-5">
|
|
<dl className={styles.stats}>
|
|
{[
|
|
{
|
|
label: t("achievements"),
|
|
value: format.number(userSettings?.achievementScore ?? 0),
|
|
},
|
|
{
|
|
label: t("respects"),
|
|
value: format.number(userSettings?.respectsReceived ?? 0),
|
|
},
|
|
{ label: t("friends"), value: format.number(friendCount) },
|
|
{ label: t("lastLogin"), value: date(user.lastLogin) },
|
|
].map((stat) => (
|
|
<div key={stat.label}>
|
|
<dt className="muted">{stat.label}</dt>
|
|
<dd>{stat.value}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</SurfaceCard>
|
|
</div>
|
|
<aside className={styles.stack}>
|
|
<SurfaceCard title={t("referrals")} bodyClassName="p-5">
|
|
<p>{t("referralHint")}</p>
|
|
<div className={styles.progressLabel}>
|
|
<strong>
|
|
{referralTotal}/{needed}
|
|
</strong>
|
|
<span className="muted">{t("referralProgress")}</span>
|
|
</div>
|
|
<progress
|
|
className={styles.progress}
|
|
value={Math.min(referralTotal, needed)}
|
|
max={needed}
|
|
aria-label={t("referralProgress")}
|
|
/>
|
|
<p className={styles.reward}>
|
|
{rewardKind && (
|
|
<CurrencyIcon kind={rewardKind} size={20} alt="" />
|
|
)}
|
|
{rewardAmount > 0
|
|
? t("reward", {
|
|
amount: format.number(rewardAmount),
|
|
currency: t.has(`currencies.${rewardCurrency}`)
|
|
? t(`currencies.${rewardCurrency}`)
|
|
: rewardCurrency,
|
|
count: needed,
|
|
})
|
|
: t("rewardHint", { count: needed })}
|
|
</p>
|
|
<label htmlFor="referral-link">{t("referralLink")}</label>
|
|
<input
|
|
id="referral-link"
|
|
value={referralLink}
|
|
readOnly
|
|
className={styles.referralInput}
|
|
/>
|
|
<CopyReferralButton value={referralLink} />
|
|
{canClaim ? (
|
|
<form action={claimReferral} className={styles.claim}>
|
|
<button type="submit" className="btn btn-primary">
|
|
{t("claim")}
|
|
</button>
|
|
</form>
|
|
) : (
|
|
<p className="muted" role="status">
|
|
{t("remaining", { count: remaining })}
|
|
</p>
|
|
)}
|
|
<details className={styles.rules}>
|
|
<summary>{t("rules")}</summary>
|
|
<p>{t("rulesText")}</p>
|
|
</details>
|
|
</SurfaceCard>
|
|
<SurfaceCard title={t("badges")} bodyClassName="p-5">
|
|
{badges.length === 0 ? (
|
|
<p className="muted">{t("noBadges")}</p>
|
|
) : (
|
|
<div className={styles.badges}>
|
|
{badges.map((badge) => (
|
|
<img
|
|
key={badge.badgeCode}
|
|
src={`https://images.habbo.com/c_images/album1584/${encodeURIComponent(badge.badgeCode)}.gif`}
|
|
alt={badge.badgeCode}
|
|
title={badge.badgeCode}
|
|
width={40}
|
|
height={40}
|
|
loading="lazy"
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</SurfaceCard>
|
|
</aside>
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|