feat(me): organize personal dashboard and localize user actions
CI / check (push) Successful in 1m41s
CI / deploy (push) Successful in 1m29s
CI / e2e (push) Successful in 23s

This commit is contained in:
Simo committed 2026-09-06 11:30:24 +02:00
1 parent 2840ef5d9d
commit 85a6df162b
9 files changed
+768 -576

No files matched your search

+25 -14
View File
@@ -1,11 +1,16 @@
"use client"; "use client";
import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
export default function CopyReferralButton({ value }: { value: string }) { export default function CopyReferralButton({ value }: { value: string }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [failed, setFailed] = useState(false);
const t = useTranslations("pages.myDashboard");
async function copy() { async function copy() {
setFailed(false);
setCopied(false);
const absolute = const absolute =
typeof window !== "undefined" && value.startsWith("/") typeof window !== "undefined" && value.startsWith("/")
? `${window.location.origin}${value}` ? `${window.location.origin}${value}`
@@ -20,9 +25,11 @@ export default function CopyReferralButton({ value }: { value: string }) {
document.body.appendChild(ta); document.body.appendChild(ta);
ta.select(); ta.select();
try { try {
document.execCommand("copy"); if (!document.execCommand("copy")) throw new Error("copy-failed");
} catch { } catch {
/* give up silently */ setFailed(true);
document.body.removeChild(ta);
return;
} }
document.body.removeChild(ta); document.body.removeChild(ta);
} }
@@ -31,17 +38,21 @@ export default function CopyReferralButton({ value }: { value: string }) {
} }
return ( return (
<button <>
type="button" <button
onClick={copy} type="button"
className="rounded-xl font-bold text-sm px-5 py-2.5 transition-all duration-200 hover:scale-[1.02] active:scale-95 shrink-0" onClick={copy}
style={{ className="rounded-xl font-bold text-sm px-5 py-2.5 transition-all duration-200 hover:scale-[1.02] active:scale-95 shrink-0"
background: "color-mix(in srgb, var(--color-primary) 12%, transparent)", style={{
color: "var(--color-primary-readable, var(--color-primary))", background:
border: `2px solid color-mix(in srgb, var(--color-primary) 20%, transparent)`, "color-mix(in srgb, var(--color-primary) 12%, transparent)",
}} color: "var(--color-primary-readable, var(--color-primary))",
> border: `2px solid color-mix(in srgb, var(--color-primary) 20%, transparent)`,
{copied ? "Copied!" : "Copy link"} }}
</button> >
{copied ? t("copied") : t("copy")}
</button>
{failed && <p role="alert">{t("copyFailed")}</p>}
</>
); );
} }
+249
View File
@@ -0,0 +1,249 @@
.dashboard {
display: grid;
gap: 20px;
padding-bottom: 24px;
min-width: 0;
}
.hero {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 24px;
padding: 24px;
background: linear-gradient(
130deg,
color-mix(in srgb, var(--color-primary) 10%, var(--color-surface)),
var(--color-surface)
);
}
.avatar {
flex-shrink: 0;
object-fit: contain;
image-rendering: pixelated;
border-radius: 16px;
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
}
.identity {
flex: 1;
min-width: 180px;
overflow-wrap: anywhere;
}
.identity h1 {
font-size: clamp(1.6rem, 3vw, 2rem);
margin: 4px 0;
}
.dates {
font-size: 0.8rem;
color: var(--color-text-muted);
margin-top: 12px;
}
.shortcuts {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.shortcut {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 14px;
border-radius: 12px;
background: var(--color-surface);
border: 1px solid var(--color-border);
font-weight: 600;
color: var(--color-text-readable);
text-decoration: none;
}
.shortcut:hover,
.friend:hover,
.room:hover {
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
}
.count {
border-radius: 999px;
padding: 2px 7px;
background: var(--color-danger);
color: white;
font-size: 0.75rem;
}
.wallet {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.currency {
display: flex;
align-items: center;
gap: 14px;
padding: 20px;
}
.currency > div {
display: grid;
min-width: 0;
}
.currency strong {
font-size: 1.3rem;
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
.currency span {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.columns {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 350px);
gap: 20px;
align-items: start;
}
.stack {
display: grid;
gap: 20px;
min-width: 0;
}
.friends,
.rooms {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr));
gap: 12px;
}
.friend,
.room {
display: flex;
gap: 12px;
padding: 12px;
border: 1px solid var(--color-border);
border-radius: 12px;
color: var(--color-text-readable);
text-decoration: none;
min-width: 0;
}
.friend > div,
.room {
display: grid;
min-width: 0;
}
.friend strong,
.friend span,
.room strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.friend span,
.room span {
font-size: 0.8rem;
}
.stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
margin: 0;
}
.stats dt {
font-size: 0.8rem;
}
.stats dd {
margin: 4px 0 0;
font-weight: 700;
}
.feedback {
margin: 0;
border: 1px solid var(--color-border);
border-left: 4px solid var(--color-primary);
border-radius: 12px;
background: var(--color-surface);
padding: 16px;
}
.progressLabel {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 16px;
font-size: 0.85rem;
}
.progress {
width: 100%;
height: 10px;
accent-color: var(--color-primary);
}
.reward {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
}
.referralInput {
width: 100%;
min-width: 0;
margin: 8px 0;
}
.claim {
margin-top: 16px;
}
.claim button {
width: 100%;
}
.rules {
border-top: 1px solid var(--color-border);
margin-top: 16px;
padding-top: 12px;
font-size: 0.8rem;
}
.rules summary {
cursor: pointer;
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.badges img {
object-fit: contain;
image-rendering: pixelated;
}
@media (max-width: 760px) {
.columns {
grid-template-columns: minmax(0, 1fr);
}
.shortcuts {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.hero {
padding: 18px;
gap: 16px;
}
.hero > a {
width: 100%;
text-align: center;
}
.currency {
padding: 12px;
gap: 8px;
flex-wrap: wrap;
}
.currency strong {
font-size: 1rem;
}
}
.progress {
appearance: none;
border: 0;
border-radius: 999px;
overflow: hidden;
background: var(--color-border);
}
.progress::-webkit-progress-bar {
background: var(--color-border);
border-radius: 999px;
}
.progress::-webkit-progress-value {
background: var(--color-primary);
border-radius: 999px;
}
.progress::-moz-progress-bar {
background: var(--color-primary);
border-radius: 999px;
}
+306 -559
View File
@@ -1,367 +1,233 @@
import Image from "next/image";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import type { CSSProperties, ReactNode } from "react"; import { getFormatter, getTranslations } from "next-intl/server";
import { claimReferral } from "@/actions/referral"; import { claimReferral } from "@/actions/referral";
import Link from "@/components/link"; import Link from "@/components/link";
import {
import { Reveal } from "@/components/motion-reveal"; CurrencyIcon,
type CurrencyKind,
} from "@/components/shared/currency-icon";
import { ProfileImage } from "@/components/shared/profile-image";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { SurfaceCard } from "@/components/surface-card"; import { SurfaceCard } from "@/components/surface-card";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { loadUserDashboard } from "@/lib/services/user-dashboard"; import { loadUserDashboard } from "@/lib/services/user-dashboard";
import CopyReferralButton from "./CopyReferralButton"; import CopyReferralButton from "./CopyReferralButton";
import styles from "./dashboard.module.css";
export const metadata = { title: "My dashboard" }; export async function generateMetadata() {
const t = await getTranslations("pages.myDashboard");
type SearchParams = Promise<{ claimed?: string; error?: string }>; return { title: t("title") };
function feedbackStyle(tone: "success" | "error" | "warning"): CSSProperties {
const accent =
tone === "error"
? "var(--color-danger)"
: tone === "warning"
? "var(--color-accent)"
: "var(--color-primary)";
return {
margin: 0,
padding: "0.85rem 1rem",
borderRadius: "var(--radius-md)",
border: `1px solid ${accent}`,
color: "var(--color-text-readable, var(--color-text))",
fontSize: "0.9rem",
fontWeight: 600,
background: "var(--color-surface)",
borderLeft: `4px solid ${accent}`,
};
}
const ERROR_MESSAGES: Record<string, string> = {
not_enough: "You do not have enough referrals to claim your reward yet.",
no_referrals:
"You haven't referred anyone yet — share your link to get started.",
bad_config:
"The referral reward is not configured correctly. Please contact a staff member.",
error: "Something went wrong while claiming your reward. Please try again.",
};
function getErrorMessage(error: string): string {
return ERROR_MESSAGES[error] ?? ERROR_MESSAGES.error;
} }
export default async function MePage({ export default async function MePage({
searchParams, searchParams,
}: { }: {
searchParams: SearchParams; searchParams: Promise<{ claimed?: string; error?: string }>;
}) { }) {
const session = await auth(); const session = await auth();
if (!session?.user?.id) redirect("/login"); if (!session?.user?.id) redirect("/login");
const userId = Number(session.user.id); const userId = Number(session.user.id);
if (!Number.isFinite(userId) || userId <= 0) redirect("/login"); if (!Number.isFinite(userId) || userId <= 0) redirect("/login");
const [t, format, { claimed, error }] = await Promise.all([
const { claimed, error } = await searchParams; getTranslations("pages.myDashboard"),
getFormatter(),
let content: ReactNode; searchParams,
]);
let data: Awaited<ReturnType<typeof loadUserDashboard>>;
try { try {
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584"; data = await loadUserDashboard(userId);
} catch {
const { return (
userRows, <main>
hotelName, <SurfaceCard className="p-6">
neededRaw, <p role="alert">{t("loadError")}</p>
rewardAmountRaw, <Link href="/me" className="btn btn-outline">
currencySettings, {t("retry")}
alertRaw, </Link>
recentRooms, </SurfaceCard>
badges, </main>
userSettingsRows, );
friendCountRows, }
unreadCountRows, const user = data.userRows[0];
referralsRows, if (!user)
friends, return (
} = await loadUserDashboard(userId); <main>
<SurfaceCard className="p-6">
const user = userRows[0] ?? null; <p role="alert">{t("loadError")}</p>
if (!user) throw new Error("user-not-found"); <Link href="/login" className="btn btn-outline">
{t("login")}
const userSettings = userSettingsRows[0] ?? null; </Link>
const friendCount = Number(friendCountRows[0]?.value ?? 0); </SurfaceCard>
const unreadCount = Number(unreadCountRows[0]?.value ?? 0); </main>
);
const needed = Number.parseInt(neededRaw ?? "5", 10) || 5; const {
const rewardAmount = Number.parseInt(rewardAmountRaw ?? "30", 10) || 0; hotelName,
const rewardCurrency = ( neededRaw,
currencySettings.referral_reward_currency_type ?? rewardAmountRaw,
currencySettings.referral_reward_currency ?? currencySettings,
"diamonds" alertRaw,
) recentRooms,
.trim() badges,
.toLowerCase(); userSettingsRows,
friendCountRows,
const referralTotal = referralsRows[0] unreadCountRows,
? Number(referralsRows[0].referralsTotal) referralsRows,
: 0; friends,
const canClaim = referralTotal >= needed; currencyRows,
const remaining = Math.max(0, needed - referralTotal); } = data;
const userSettings = userSettingsRows[0];
const onlineFriends = friends.filter((f) => f.online === "1"); const friendCount = Number(friendCountRows[0]?.value ?? 0);
const unreadCount = Number(unreadCountRows[0]?.value ?? 0);
const registered = new Date(user.accountCreated * 1000) const needed = Math.max(1, Number.parseInt(neededRaw ?? "5", 10) || 5);
.toISOString() const rewardAmount = Number.parseInt(rewardAmountRaw ?? "30", 10) || 0;
.slice(0, 10); const rewardCurrency = (
const lastLoginDate = user.lastLogin currencySettings.referral_reward_currency_type ??
? new Date(user.lastLogin * 1000).toISOString().slice(0, 10) currencySettings.referral_reward_currency ??
: "Never"; "diamonds"
const lastOnlineDate = user.lastOnline )
? new Date(user.lastOnline * 1000).toISOString().slice(0, 10) .trim()
: "Never"; .toLowerCase();
const referralLink = `/register?ref=${encodeURIComponent(user.username)}`; const rewardKind = ["credits", "duckets", "diamonds", "points"].includes(
rewardCurrency,
const avatarFull = avatarImageUrl(user.look, { )
direction: 2, ? (rewardCurrency as CurrencyKind)
}); : null;
const referralTotal = Math.max(
content = ( 0,
<div className="flex flex-col gap-8 pb-8"> Number(referralsRows[0]?.referralsTotal ?? 0),
{claimed ? ( );
<div role="status" style={feedbackStyle("success")}> const canClaim = referralTotal >= needed;
Woah! You have successfully claimed your reward — keep up the good const remaining = Math.max(0, needed - referralTotal);
work! const onlineFriends = [
</div> ...new Map(
) : null} friends
{error ? ( .filter((friend) => friend.online === "1")
<div role="alert" style={feedbackStyle("error")}> .map((friend) => [friend.id, friend]),
{getErrorMessage(error)} ).values(),
</div> ];
) : null} const date = (value: number) =>
value
<Reveal> ? format.dateTime(new Date(value * 1000), { dateStyle: "medium" })
<SurfaceCard : t("never");
className="relative p-5 sm:p-6 flex flex-col sm:flex-row sm:items-start gap-5 sm:gap-6" const referralLink = `/register?ref=${encodeURIComponent(user.username)}`;
style={{ const avatarFull = avatarImageUrl(user.look, { direction: 2 });
background: `linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 12%, var(--color-surface)), var(--color-surface))`, const wallet = [
borderColor: { kind: "credits", value: user.credits },
"color-mix(in srgb, var(--color-primary) 20%, transparent)", {
}} kind: "duckets",
> value:
<div currencyRows === null
className="absolute -top-10 -right-10 w-40 h-40 rounded-full opacity-10" ? null
style={{ background: "var(--color-primary)" }} : (currencyRows.find((row) => row.type === 0)?.amount ?? 0),
/> },
<div className="relative shrink-0 flex flex-col items-center gap-3 mx-auto sm:mx-0"> {
<div kind: "diamonds",
className="absolute -inset-3 rounded-full opacity-[0.15] blur-[40px]" value:
style={{ background: "var(--color-primary)" }} currencyRows === null
/> ? null
<Image : (currencyRows.find((row) => row.type === 5)?.amount ?? 0),
src={avatarFull} },
alt="" ] as const;
width={120} const errorKey =
height={160} error &&
className="object-contain drop-shadow-xl relative" ["not_enough", "no_referrals", "bad_config", "ratelimit"].includes(error)
unoptimized ? error
priority : "error";
/> return (
<Link <main className={styles.dashboard}>
href="/client" {claimed && (
className="btn-shine flex items-center justify-center gap-1.5 rounded-xl border py-2 px-4 font-extrabold text-xs transition-all duration-200 hover:scale-[1.02] active:scale-95 shadow-lg" <p role="status" className={styles.feedback}>
style={{ {t("claimed")}
background: "var(--color-primary)", </p>
color: "var(--color-primary-foreground)", )}
borderColor: {error && (
"color-mix(in srgb, var(--color-primary) 20%, transparent)", <p role="alert" className={styles.feedback}>
boxShadow: {t(`errors.${errorKey}`)}
"0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)", </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 })}
> >
<Image {link.count > 99 ? "99+" : link.count}
src="/assets/images/EnterHubbly.png" </span>
alt="" )}
width={16} </Link>
height={16} ))}
unoptimized </nav>
/> {alertRaw && <p className={styles.feedback}>{alertRaw}</p>}
Enter {hotelName} <section aria-label={t("wallet")} className={styles.wallet}>
</Link> {wallet.map((currency) => (
</div> <SurfaceCard key={currency.kind} className={styles.currency}>
<div className="flex-1 min-w-0 sm:pt-1 text-center sm:text-left"> <CurrencyIcon kind={currency.kind} size={28} alt="" />
<div <div>
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[11px] font-bold uppercase tracking-wider mb-3 border" <strong>
style={{ {currency.value === null ? "—" : format.number(currency.value)}
borderColor: </strong>
"color-mix(in srgb, var(--color-primary) 18%, transparent)", <span>{t(`currencies.${currency.kind}`)}</span>
background:
"color-mix(in srgb, var(--color-primary) 10%, transparent)",
color: "var(--color-primary-readable, var(--color-primary))",
}}
>
Rank {user.rank}
</div>
<h1
className="text-2xl md:text-3xl font-black tracking-tight"
style={{ color: "var(--color-text-readable)" }}
>
{user.username}
</h1>
<p
className="text-sm mt-1"
style={{ color: "var(--color-text-muted)" }}
>
{user.motto || "No motto"}
</p>
<div className="flex flex-wrap gap-x-6 gap-y-1 mt-4 text-xs">
<span style={{ color: "var(--color-text-muted)" }}>
Registered:{" "}
<strong style={{ color: "var(--color-text-readable)" }}>
{registered}
</strong>
</span>
<span style={{ color: "var(--color-text-muted)" }}>
Last online:{" "}
<strong style={{ color: "var(--color-text-readable)" }}>
{lastOnlineDate}
</strong>
</span>
</div>
</div> </div>
</SurfaceCard> </SurfaceCard>
</Reveal> ))}
</section>
{alertRaw ? ( <div className={styles.columns}>
<Reveal delay={0.05}> <div className={styles.stack}>
<SurfaceCard <SurfaceCard
className="px-6 py-4 text-center" title={t("onlineFriends")}
style={{ actionHref="/friends"
background: actionLabel={t("allFriends")}
"color-mix(in srgb, var(--color-accent) 12%, transparent)", bodyClassName="p-5"
borderColor: >
"color-mix(in srgb, var(--color-accent) 25%, transparent)",
}}
>
<p
className="text-sm font-bold"
style={{ color: "var(--color-accent)" }}
>
{alertRaw}
</p>
</SurfaceCard>
</Reveal>
) : null}
<Reveal delay={0.1}>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
{[
{ value: user.credits.toLocaleString(), label: "Credits" },
{ value: `Rank ${user.rank}`, label: "Hotel rank" },
{
value: (userSettings?.achievementScore ?? 0).toLocaleString(),
label: "Achievement score",
},
{
value: (userSettings?.respectsReceived ?? 0).toLocaleString(),
label: "Respects",
},
{ value: friendCount.toLocaleString(), label: "Friends" },
{ value: lastLoginDate, label: "Last login" },
].map((s) => (
<SurfaceCard
key={s.label}
className="relative p-5 text-center transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg"
>
<div
className="text-xl md:text-2xl font-black tracking-tight mb-0.5"
style={{ color: "var(--color-primary)" }}
>
{s.value}
</div>
<div
className="text-[11px] font-bold uppercase tracking-widest"
style={{ color: "var(--color-text-muted)" }}
>
{s.label}
</div>
</SurfaceCard>
))}
</div>
</Reveal>
<Reveal delay={0.15}>
<SurfaceCard className="p-5">
<div className="flex items-center justify-between mb-4">
<div>
<h2
className="text-lg font-black tracking-tight"
style={{ color: "var(--color-text-readable)" }}
>
Online friends
</h2>
<p
className="text-xs mt-0.5"
style={{ color: "var(--color-text-muted)" }}
>
{onlineFriends.length === 0
? "None of your friends are online right now"
: `${onlineFriends.length} of your ${friends.length} friends online`}
</p>
</div>
<Link
href="/friends"
className="text-xs font-bold hover:underline shrink-0"
style={{ color: "var(--color-primary)" }}
>
View all
</Link>
</div>
{onlineFriends.length === 0 ? ( {onlineFriends.length === 0 ? (
<div className="text-center py-6"> <p className="muted">{t("noOnlineFriends")}</p>
<p
className="text-sm"
style={{ color: "var(--color-text-muted)" }}
>
No friends online right now. Check back later!
</p>
</div>
) : ( ) : (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3"> <div className={styles.friends}>
{onlineFriends.map((friend) => ( {onlineFriends.map((friend) => (
<Link <Link
key={friend.id} key={friend.id}
href={`/u/${friend.username}`} href={`/u/${encodeURIComponent(friend.username)}`}
className="flex items-center gap-3 p-3 rounded-xl transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md" className={styles.friend}
style={{
background:
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
textDecoration: "none",
}}
> >
<UserAvatarThumbnail <UserAvatarThumbnail figure={friend.look} alt="" />
figure={friend.look} <div>
alt="" <strong>{friend.username}</strong>
className="rounded-lg shrink-0" <span className="muted">
/> {friend.motto || t("noMotto")}
<div className="min-w-0">
<p
className="text-sm font-extrabold truncate"
style={{ color: "var(--color-text-readable)" }}
>
{friend.username}
</p>
<p
className="text-[10px] font-semibold truncate mt-0.5"
style={{ color: "var(--color-text-muted)" }}
>
{friend.motto || "No motto"}
</p>
<span
className="inline-flex items-center gap-1 text-[10px] font-bold mt-1"
style={{ color: "var(--color-success)" }}
>
<span
className="w-1.5 h-1.5 rounded-full"
style={{ background: "var(--color-success)" }}
/>{" "}
Online
</span> </span>
</div> </div>
</Link> </Link>
@@ -369,247 +235,128 @@ export default async function MePage({
</div> </div>
)} )}
</SurfaceCard> </SurfaceCard>
</Reveal> <SurfaceCard
title={t("recentRooms")}
<Reveal delay={0.2}> actionHref="/community"
<SurfaceCard className="p-5"> actionLabel={t("community")}
<div className="mb-4"> bodyClassName="p-5"
<h2 >
className="text-lg font-black tracking-tight" <p className="muted">{t("roomsHint")}</p>
style={{ color: "var(--color-text-readable)" }} {recentRooms.length === 0 ? (
> <p className="muted">{t("noRooms")}</p>
Refer a friend ({referralTotal}/{needed}) ) : (
</h2> <div className={styles.rooms}>
<p
className="text-xs mt-0.5"
style={{ color: "var(--color-text-muted)" }}
>
Refer new users and earn in-game rewards
</p>
</div>
<div className="flex flex-col gap-4">
<p
className="text-sm"
style={{ color: "var(--color-text-muted)" }}
>
{rewardAmount > 0
? `For every ${needed} users who register through your referral link you can claim a reward of ${rewardAmount.toLocaleString()} ${rewardCurrency}!`
: `Invite ${needed} users through your referral link to claim a reward!`}
</p>
<div role="note" style={feedbackStyle("warning")}>
Boosting referrals by making your own accounts will lead to
removal of all progress, currency, inventory and a potential
ban.
</div>
<div>
<label
htmlFor="referral-link"
className="text-xs font-bold mb-1.5 block"
style={{ color: "var(--color-text-muted)" }}
>
Your referral link
</label>
<div className="flex gap-2 flex-wrap">
<input
id="referral-link"
type="text"
readOnly
defaultValue={referralLink}
className="input-glow flex-1 min-w-0 rounded-xl border-2 px-4 py-2.5 text-sm font-medium"
style={{
backgroundColor: "var(--color-background)",
color: "var(--color-text-readable, var(--color-text))",
borderColor:
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
/>
<CopyReferralButton value={referralLink} />
</div>
</div>
{canClaim ? (
<form action={claimReferral}>
<button
type="submit"
className="btn-shine w-full rounded-xl font-extrabold text-sm py-3.5 transition-all duration-200 hover:scale-[1.02] active:scale-95 shadow-lg"
style={{
background: "var(--color-primary)",
color: "var(--color-primary-foreground)",
boxShadow:
"0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)",
}}
>
Claim your referral reward!
</button>
</form>
) : (
<button
type="button"
disabled
className="w-full rounded-xl font-extrabold text-sm py-3.5 opacity-60 cursor-not-allowed"
style={{
background:
"color-mix(in srgb, var(--color-text-muted) 15%, transparent)",
color: "var(--color-text-muted)",
}}
>
{`Refer ${remaining} more ${remaining === 1 ? "user" : "users"} to unlock your reward`}
</button>
)}
</div>
</SurfaceCard>
</Reveal>
{recentRooms.length > 0 && (
<Reveal delay={0.25}>
<SurfaceCard className="p-5">
<div className="flex items-center justify-between mb-4">
<div>
<h2
className="text-lg font-black tracking-tight"
style={{ color: "var(--color-text-readable)" }}
>
Recent rooms
</h2>
<p
className="text-xs mt-0.5"
style={{ color: "var(--color-text-muted)" }}
>
Newest rooms in the hotel
</p>
</div>
<Link
href="/community"
className="text-xs font-bold hover:underline shrink-0"
style={{ color: "var(--color-primary)" }}
>
View all
</Link>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{recentRooms.map((room) => ( {recentRooms.map((room) => (
<Link <Link
key={room.id} key={room.id}
href={`/room/${room.id}`} href={`/room/${room.id}`}
className="block rounded-xl border p-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md" className={styles.room}
style={{
background:
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
borderColor:
"color-mix(in srgb, var(--color-primary) 10%, transparent)",
}}
> >
<p <strong>{room.name}</strong>
className="text-sm font-extrabold truncate" <span className="muted">
style={{ color: "var(--color-text-readable)" }} {room.ownerName} · {room.users}/{room.usersMax}
> </span>
{room.name}
</p>
<p
className="text-[10px] font-semibold mt-0.5"
style={{ color: "var(--color-text-muted)" }}
>
{room.ownerName} &middot; {room.users}/{room.usersMax}
</p>
</Link> </Link>
))} ))}
</div> </div>
</SurfaceCard> )}
</Reveal> </SurfaceCard>
)} <SurfaceCard title={t("activity")} bodyClassName="p-5">
<dl className={styles.stats}>
{badges.length > 0 && ( {[
<Reveal delay={0.3}> {
<SurfaceCard className="p-5"> label: t("achievements"),
<div className="mb-4"> value: format.number(userSettings?.achievementScore ?? 0),
<h2 },
className="text-lg font-black tracking-tight" {
style={{ color: "var(--color-text-readable)" }} label: t("respects"),
> value: format.number(userSettings?.respectsReceived ?? 0),
Badges },
</h2> { label: t("friends"), value: format.number(friendCount) },
<p { label: t("lastLogin"), value: date(user.lastLogin) },
className="text-xs mt-0.5" ].map((stat) => (
style={{ color: "var(--color-text-muted)" }} <div key={stat.label}>
> <dt className="muted">{stat.label}</dt>
{badges.length} badge{badges.length !== 1 ? "s" : ""} equipped <dd>{stat.value}</dd>
</p> </div>
</div> ))}
<div className="flex flex-wrap gap-2"> </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) => ( {badges.map((badge) => (
<Image <img
key={badge.badgeCode} key={badge.badgeCode}
src={`${BADGE_IMG_BASE}/${badge.badgeCode}.gif`} src={`https://images.habbo.com/c_images/album1584/${encodeURIComponent(badge.badgeCode)}.gif`}
alt={badge.badgeCode} alt={badge.badgeCode}
title={badge.badgeCode}
width={40} width={40}
height={40} height={40}
className="rounded-md transition-transform hover:scale-110" loading="lazy"
unoptimized
/> />
))} ))}
</div> </div>
</SurfaceCard> )}
</Reveal>
)}
<Reveal delay={0.35}>
<SurfaceCard className="p-5">
<h2
className="text-lg font-black tracking-tight mb-4"
style={{ color: "var(--color-text-readable)" }}
>
Quick links
</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ href: "/messages", label: "Messages", badge: unreadCount },
{ href: `/u/${user.username}`, label: "My profile", badge: 0 },
{ href: "/settings", label: "Settings", badge: 0 },
{ href: "/shop", label: "Shop", badge: 0 },
].map((link) => (
<Link
key={link.href}
href={link.href}
className="relative flex items-center justify-center rounded-xl border p-3 text-sm font-bold transition-all hover:-translate-y-0.5 hover:shadow-md"
style={{
background:
"color-mix(in srgb, var(--color-primary) 6%, transparent)",
borderColor:
"color-mix(in srgb, var(--color-primary) 18%, transparent)",
color: "var(--color-text-readable)",
}}
>
{link.label}
{link.badge > 0 && (
<span
className="absolute -top-1.5 -right-1.5 flex min-w-[18px] h-[18px] items-center justify-center rounded-full px-1 text-[10px] font-bold leading-none"
style={{
background: "var(--color-danger)",
color: "white",
}}
>
{link.badge > 99 ? "99+" : link.badge}
</span>
)}
</Link>
))}
</div>
</SurfaceCard> </SurfaceCard>
</Reveal> </aside>
</div> </div>
); </main>
} catch { );
content = (
<SurfaceCard className="p-6 text-center">
<p
className="text-sm font-bold"
style={{ color: "var(--color-text-muted)" }}
>
We couldn&apos;t load your dashboard right now. Please try again
shortly.
</p>
</SurfaceCard>
);
}
return <main>{content}</main>;
} }
+1 -1
View File
@@ -8,6 +8,7 @@ import { sendFriendRequest } from "@/actions/social";
import Link from "@/components/link"; import Link from "@/components/link";
import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui"; import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
import { CurrencyIcon } from "@/components/shared/currency-icon"; import { CurrencyIcon } from "@/components/shared/currency-icon";
import { ProfileImage } from "@/components/shared/profile-image";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { import {
@@ -25,7 +26,6 @@ import {
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
import styles from "./profile.module.css"; import styles from "./profile.module.css";
import { ProfileImage } from "./profile-image";
type SearchParams = Promise<{ type SearchParams = Promise<{
friend?: string; friend?: string;
+53
View File
@@ -0,0 +1,53 @@
import { readFileSync } from "node:fs";
import { createTranslator } from "next-intl";
import { expect, it } from "vitest";
import en from "@/messages/en.json";
import italian from "@/messages/it.json";
it.each([
["en", en],
["it", italian],
] as const)("renders all dashboard messages in %s", (locale, messages) => {
const errors: unknown[] = [];
const t = createTranslator({
locale,
messages: messages as typeof en,
namespace: "pages.myDashboard",
onError: (error) => errors.push(error),
});
const keys = new Set(
["credits", "duckets", "diamonds", "points"].map(
(key) => `currencies.${key}`,
),
);
for (const error of [
"not_enough",
"no_referrals",
"bad_config",
"ratelimit",
"error",
])
keys.add(`errors.${error}`);
for (const file of ["page.tsx", "CopyReferralButton.tsx"]) {
for (const match of readFileSync(
`src/app/(site)/me/${file}`,
"utf8",
).matchAll(/\bt\("([^"]+)"/g))
keys.add(match[1]);
}
for (const key of keys) {
const message = key as Parameters<typeof t>[0];
expect(t.has(message), key).toBe(true);
expect(
t(message, {
count: 3,
amount: "30",
currency: "Diamanti",
hotel: "Epicnabbo",
date: "06/09/2026",
}),
key,
).not.toBe("");
}
expect(errors).toEqual([]);
});
+14 -2
View File
@@ -1,5 +1,5 @@
import "server-only"; import "server-only";
import { and, asc, count, desc, eq, gt, or } from "drizzle-orm"; import { and, asc, count, desc, eq, gt, inArray, or } from "drizzle-orm";
import { import {
db, db,
MessengerFriendships, MessengerFriendships,
@@ -8,6 +8,7 @@ import {
User, User,
UserReferrals, UserReferrals,
UsersBadges, UsersBadges,
UsersCurrency,
UsersSettings, UsersSettings,
} from "@/lib/db"; } from "@/lib/db";
import { resolveHotelName } from "@/lib/hotel-name"; import { resolveHotelName } from "@/lib/hotel-name";
@@ -27,6 +28,7 @@ export async function loadUserDashboard(userId: number) {
unreadCountRows, unreadCountRows,
referralsRows, referralsRows,
friends, friends,
currencyRows,
] = await Promise.all([ ] = await Promise.all([
db db
.select({ .select({
@@ -34,7 +36,6 @@ export async function loadUserDashboard(userId: number) {
username: User.username, username: User.username,
look: User.look, look: User.look,
motto: User.motto, motto: User.motto,
rank: User.rank,
credits: User.credits, credits: User.credits,
accountCreated: User.accountCreated, accountCreated: User.accountCreated,
lastLogin: User.lastLogin, lastLogin: User.lastLogin,
@@ -132,6 +133,16 @@ export async function loadUserDashboard(userId: number) {
), ),
) )
.catch(() => []), .catch(() => []),
db
.select({ type: UsersCurrency.type, amount: UsersCurrency.amount })
.from(UsersCurrency)
.where(
and(
eq(UsersCurrency.userId, userId),
inArray(UsersCurrency.type, [0, 5]),
),
)
.catch(() => null),
]); ]);
return { return {
@@ -148,5 +159,6 @@ export async function loadUserDashboard(userId: number) {
unreadCountRows, unreadCountRows,
referralsRows, referralsRows,
friends, friends,
currencyRows,
}; };
} }
+60
View File
@@ -3475,6 +3475,66 @@
"description": "Try again later or contact an administrator." "description": "Try again later or contact an administrator."
} }
} }
},
"myDashboard": {
"title": "My dashboard",
"loadError": "We could not load your dashboard. Please try again.",
"retry": "Try again",
"login": "Sign in",
"never": "Never",
"claimed": "Reward claimed successfully!",
"welcome": "Your space on {hotel}",
"noMotto": "No motto yet",
"registered": "Registered: {date}",
"lastOnline": "Last online: {date}",
"enter": "Enter {hotel}",
"quickLinks": "Quick links",
"messages": "Messages",
"profile": "My profile",
"settings": "Settings",
"shop": "Shop",
"unread": "{count} unread messages",
"wallet": "Your wallet",
"currencies": {
"credits": "Credits",
"duckets": "Duckets",
"diamonds": "Diamonds",
"points": "Points"
},
"onlineFriends": "Online friends",
"allFriends": "All friends",
"noOnlineFriends": "None of your friends are online right now.",
"recentRooms": "New rooms",
"community": "Community",
"roomsHint": "The newest rooms created in the hotel.",
"noRooms": "No rooms available yet.",
"activity": "Your activity",
"achievements": "Achievement score",
"respects": "Respects received",
"friends": "Friends",
"lastLogin": "Last login",
"referrals": "Invite your friends",
"referralHint": "Share your link and earn rewards when new players register.",
"referralProgress": "Progress towards your reward",
"reward": "{amount} {currency} for every {count} new players.",
"rewardHint": "Invite {count} players to unlock your reward.",
"referralLink": "Your invitation link",
"claim": "Claim reward",
"remaining": "{count, plural, one {Invite # more player to unlock your reward.} other {Invite # more players to unlock your reward.}}",
"rules": "Invitation rules",
"rulesText": "Creating extra accounts to increase invitations can lead to removal of progress, currency and inventory, and a ban.",
"badges": "Equipped badges",
"noBadges": "You are not wearing any badges yet.",
"copy": "Copy link",
"copied": "Copied!",
"copyFailed": "Copy failed. Select the link and copy it manually.",
"errors": {
"not_enough": "You need more invitations to claim the reward.",
"no_referrals": "Share your link to invite your first friend.",
"bad_config": "The reward is not configured correctly. Contact the staff.",
"ratelimit": "Too many attempts. Wait a moment and try again.",
"error": "The reward could not be claimed. Please try again."
}
} }
}, },
"toolbar": { "toolbar": {
+60
View File
@@ -3442,6 +3442,66 @@
"description": "Riprova più tardi o contatta un amministratore." "description": "Riprova più tardi o contatta un amministratore."
} }
} }
},
"myDashboard": {
"title": "La mia pagina",
"loadError": "Impossibile caricare la tua pagina. Riprova.",
"retry": "Riprova",
"login": "Accedi",
"never": "Mai",
"claimed": "Premio riscattato!",
"welcome": "Il tuo spazio su {hotel}",
"noMotto": "Nessun motto",
"registered": "Iscritto dal {date}",
"lastOnline": "Ultima presenza: {date}",
"enter": "Entra in {hotel}",
"quickLinks": "Collegamenti rapidi",
"messages": "Messaggi",
"profile": "Il mio profilo",
"settings": "Impostazioni",
"shop": "Negozio",
"unread": "{count} messaggi non letti",
"wallet": "Il tuo portafoglio",
"currencies": {
"credits": "Crediti",
"duckets": "Duckets",
"diamonds": "Diamanti",
"points": "Punti"
},
"onlineFriends": "Amici online",
"allFriends": "Tutti gli amici",
"noOnlineFriends": "Nessuno dei tuoi amici è online al momento.",
"recentRooms": "Nuove stanze",
"community": "Community",
"roomsHint": "Le ultime stanze create nell’hotel.",
"noRooms": "Nessuna stanza disponibile.",
"activity": "La tua attività",
"achievements": "Punti traguardo",
"respects": "Rispetti ricevuti",
"friends": "Amici",
"lastLogin": "Ultimo accesso",
"referrals": "Invita i tuoi amici",
"referralHint": "Condividi il tuo link e ricevi premi quando si registrano nuovi giocatori.",
"referralProgress": "Progressi verso il premio",
"reward": "{amount} {currency} ogni {count} nuovi giocatori.",
"rewardHint": "Invita {count} giocatori per sbloccare il premio.",
"referralLink": "Il tuo link di invito",
"claim": "Riscatta premio",
"remaining": "{count, plural, one {Invita ancora # giocatore per sbloccare il premio.} other {Invita ancora # giocatori per sbloccare il premio.}}",
"rules": "Regole degli inviti",
"rulesText": "Creare altri account per aumentare gli inviti può comportare la rimozione di progressi, valute e inventario e il ban.",
"badges": "Distintivi indossati",
"noBadges": "Non stai ancora indossando distintivi.",
"copy": "Copia link",
"copied": "Copiato!",
"copyFailed": "Copia non riuscita. Seleziona il link e copialo manualmente.",
"errors": {
"not_enough": "Servono altri inviti per riscattare il premio.",
"no_referrals": "Condividi il link per invitare il tuo primo amico.",
"bad_config": "Il premio non è configurato correttamente. Contatta lo staff.",
"ratelimit": "Troppi tentativi. Attendi un momento e riprova.",
"error": "Non è stato possibile riscattare il premio. Riprova."
}
} }
}, },
"toolbar": { "toolbar": {