- Add persistent disk cache for rendered avatars/badges (storage/imaging) so repeats never touch the flaky local renderer and cached renders survive upstream downtime - Serve cache-first with stale-on-error; cut primary/fallback timeouts from 10s/6s to 4s/4s so failing images cannot stall pages - Avatar proxy now returns a graceful 200 silhouette instead of 502 when no renderer can produce a figure, so no broken-image glyphs appear - Badge endpoint becomes a caching proxy trying configured CDN, public Habbo CDN and local /swf copy in order, and drops the fragile IP rate limit that could blank badge streams - Route all site badge images (profile, me, badges, apply pages) through the cached proxy instead of hot-linking images.habbo.com
477 lines
14 KiB
TypeScript
477 lines
14 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, badgeImageUrl, slugify } from "@/lib/format";
|
|
import { withPublicPagePerformance } from "@/lib/public-page-performance";
|
|
import { loadDailyRewardState } from "@/lib/services/daily-rewards";
|
|
import { loadUserDashboard } from "@/lib/services/user-dashboard";
|
|
import CopyReferralButton from "./CopyReferralButton";
|
|
import DailyRewardCard from "./DailyRewardCard";
|
|
import DashboardTasks from "./DashboardTasks";
|
|
import styles from "./dashboard.module.css";
|
|
|
|
export async function generateMetadata() {
|
|
const t = await getTranslations("pages.myDashboard");
|
|
return { title: t("title") };
|
|
}
|
|
|
|
async function MePage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<{
|
|
claimed?: string;
|
|
error?: string;
|
|
daily?: 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, daily }] = 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>
|
|
);
|
|
}
|
|
// Daily reward state (settings + schedule + the user's last claim). Never
|
|
// throws: on any DB hiccup it degrades to a disabled state.
|
|
const dailyState = await loadDailyRewardState(userId);
|
|
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,
|
|
dashboardEvent,
|
|
} = 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 =
|
|
referralsRows !== null &&
|
|
referralTotal >= needed &&
|
|
rewardAmount > 0 &&
|
|
rewardKind !== null;
|
|
const remaining = Math.max(0, needed - referralTotal);
|
|
const onlineFriends = friends.filter((friend) => friend.online === "1");
|
|
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>
|
|
)}
|
|
{daily === "claimed" && (
|
|
<p role="status" className={styles.feedback}>
|
|
{t("dailyClaimed")}
|
|
</p>
|
|
)}
|
|
{daily &&
|
|
daily !== "claimed" &&
|
|
[
|
|
"already_claimed",
|
|
"disabled",
|
|
"bad_config",
|
|
"ratelimit",
|
|
"error",
|
|
].includes(daily) && (
|
|
<p role="alert" className={styles.feedback}>
|
|
{t(`dailyErrors.${daily}`)}
|
|
</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}>
|
|
<span>
|
|
{link.label}
|
|
{link.href === "/messages" && (
|
|
<span className={styles.shortcutHint}>
|
|
{unreadCountRows === null
|
|
? t("messagesUnavailable")
|
|
: unreadCount > 0
|
|
? t("unread", { count: unreadCount })
|
|
: t("messagesCaughtUp")}
|
|
</span>
|
|
)}
|
|
</span>
|
|
{link.count > 0 && (
|
|
<span className={styles.count} aria-hidden="true">
|
|
{link.count > 99 ? "99+" : link.count}
|
|
</span>
|
|
)}
|
|
</Link>
|
|
))}
|
|
</nav>
|
|
{alertRaw && <p className={styles.feedback}>{alertRaw}</p>}
|
|
<DashboardTasks
|
|
userId={userId}
|
|
rewardAvailable={referralsRows === null ? null : canClaim}
|
|
dailyRewardAvailable={
|
|
dailyState.enabled && !dailyState.alreadyClaimedToday
|
|
}
|
|
/>
|
|
<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>
|
|
<SurfaceCard
|
|
title={t("eventTitle")}
|
|
actionHref="/events"
|
|
actionLabel={t("allEvents")}
|
|
bodyClassName="p-5"
|
|
>
|
|
{dashboardEvent.unavailable ? (
|
|
<p className="muted" role="status">
|
|
{t("eventsUnavailable")}
|
|
</p>
|
|
) : dashboardEvent.event ? (
|
|
<div className={styles.event}>
|
|
<div className={styles.eventDetails}>
|
|
<span className={styles.eventPhase}>
|
|
{t(
|
|
dashboardEvent.event.phase === "ongoing"
|
|
? "eventOngoing"
|
|
: "eventUpcoming",
|
|
)}
|
|
</span>
|
|
<h2>{dashboardEvent.event.title}</h2>
|
|
<p className="muted">
|
|
{dashboardEvent.event.typeName} ·{" "}
|
|
<time dateTime={dashboardEvent.event.startsAt.toISOString()}>
|
|
{format.dateTime(dashboardEvent.event.startsAt, {
|
|
dateStyle: "medium",
|
|
timeStyle: "short",
|
|
})}
|
|
</time>
|
|
</p>
|
|
</div>
|
|
<Link
|
|
href={`/events/${slugify(dashboardEvent.event.title)}-${dashboardEvent.event.id}`}
|
|
className="btn btn-outline"
|
|
>
|
|
{t("eventDetails")} →
|
|
</Link>
|
|
</div>
|
|
) : (
|
|
<p className="muted">{t("noUpcomingEvents")}</p>
|
|
)}
|
|
</SurfaceCard>
|
|
<div className={styles.columns}>
|
|
<div className={styles.stack}>
|
|
<SurfaceCard
|
|
title={t("onlineFriends")}
|
|
actionHref="/friends"
|
|
actionLabel={t("allFriends")}
|
|
bodyClassName="p-5"
|
|
>
|
|
{data.friendsUnavailable ? (
|
|
<p role="status" className="muted">
|
|
{t("loadError")}
|
|
</p>
|
|
) : 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 === null ? (
|
|
<p className="muted" role="status">
|
|
{t("roomsUnavailable")}
|
|
</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: data.userSettingsUnavailable
|
|
? "—"
|
|
: format.number(userSettings?.achievementScore ?? 0),
|
|
},
|
|
{
|
|
label: t("respects"),
|
|
value: data.userSettingsUnavailable
|
|
? "—"
|
|
: format.number(userSettings?.respectsReceived ?? 0),
|
|
},
|
|
{
|
|
label: t("friends"),
|
|
value: data.friendsUnavailable
|
|
? "—"
|
|
: 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}>
|
|
<DailyRewardCard state={dailyState} />
|
|
<SurfaceCard title={t("referrals")} bodyClassName="p-5">
|
|
<div id="referral-reward" />
|
|
<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} />
|
|
{referralsRows === null ? (
|
|
<p role="status" className="muted">
|
|
{t("tasks.rewardUnavailable")}
|
|
</p>
|
|
) : 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">
|
|
{data.badgesUnavailable ? (
|
|
<p role="status" className="muted">
|
|
{t("loadError")}
|
|
</p>
|
|
) : badges.length === 0 ? (
|
|
<p className="muted">{t("noBadges")}</p>
|
|
) : (
|
|
<div className={styles.badges}>
|
|
{badges.map((badge) => (
|
|
<img
|
|
key={badge.badgeCode}
|
|
src={badgeImageUrl(badge.badgeCode)}
|
|
alt={badge.badgeCode}
|
|
title={badge.badgeCode}
|
|
width={40}
|
|
height={40}
|
|
loading="lazy"
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</SurfaceCard>
|
|
</aside>
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
export default withPublicPagePerformance("/me", MePage);
|