import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { postGuestbook } from "@/actions/guestbook"; import { ContentCard, EmptyState, OnlineBadge, StatBlock, } from "@/components/public/ui"; import { auth } from "@/lib/auth"; import { avatarImageUrl } from "@/lib/format"; import { prisma } from "@/lib/prisma"; import { siteSettings } from "@/lib/services/site-settings"; export const dynamic = "force-dynamic"; export async function generateMetadata({ params, }: { params: Promise<{ username: string }>; }): Promise { const { username } = await params; const user = await prisma.user .findUnique({ where: { username }, select: { username: true, motto: true }, }) .catch(() => null); if (!user) return { title: "Profile" }; return { title: user.username, description: user.motto || `${user.username}'s profile`, }; } // Canonical Habbo badge image CDN. Badge codes (e.g. "ADM") map to a .gif here. const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584"; // Emulator currency type ids (see UsersCurrency model): Duckets=0, Diamonds=5. const CURRENCY_DUCKETS = 0; const CURRENCY_DIAMONDS = 5; function formatTimestamp(ts: number | null | undefined): string { if (!ts) return ""; return new Date(ts * 1000).toISOString().slice(0, 10); } function formatDate(d: Date | null | undefined): string { return d ? d.toISOString().slice(0, 10) : ""; } export default async function ProfilePage({ params, }: { params: Promise<{ username: string }>; }) { const t = await getTranslations("pages.profile"); const { username } = await params; const [user, imagerBase, session] = await Promise.all([ prisma.user.findUnique({ where: { username }, select: { id: true, username: true, motto: true, look: true, rank: true, credits: true, online: true, accountCreated: true, }, }), siteSettings.get( "habbo_imaging_url", "https://www.habbo.com/habbo-imaging/avatarimage", ), auth(), ]); if (!user) notFound(); const avatar = avatarImageUrl(imagerBase ?? "", user.look, { size: "l" }); const registered = new Date(user.accountCreated * 1000) .toISOString() .slice(0, 10); const isLoggedIn = Boolean(session?.user?.id); // Profile sub-sections. Each query is isolated so a DB hiccup degrades that // single section to empty rather than 500-ing the whole profile. // Currency ledger rows (duckets/diamonds). Credits live on the users row // itself; the alt-currencies sit in the users_currency ledger keyed by type. const currencyRows = await prisma.usersCurrency .findMany({ where: { userId: user.id, type: { in: [CURRENCY_DUCKETS, CURRENCY_DIAMONDS] }, }, select: { type: true, amount: true }, }) .catch(() => []); const ducketsAmount = currencyRows.find((c) => c.type === CURRENCY_DUCKETS)?.amount ?? 0; const diamondsAmount = currencyRows.find((c) => c.type === CURRENCY_DIAMONDS)?.amount ?? 0; const wallet = [ { key: "credits", icon: "💰", label: t("statCredits"), value: user.credits, color: "var(--color-primary-readable, var(--color-primary))", }, { key: "duckets", icon: "🪙", label: t("statDuckets"), value: ducketsAmount, color: "var(--color-accent-readable, var(--color-accent))", }, { key: "diamonds", icon: "💎", label: t("statDiamonds"), value: diamondsAmount, color: "var(--color-text-readable, var(--color-text))", }, ]; // Friends. messenger_friendships stores one directed row per pair keyed on // user_one_id; resolve the user_two_id side to usernames/looks for the grid. const friendships = await prisma.messengerFriendships .findMany({ where: { userOneId: user.id }, orderBy: { friendsSince: "desc" }, take: 18, select: { userTwoId: true }, }) .catch(() => []); const friendIds = Array.from(new Set(friendships.map((f) => f.userTwoId))); const friendUsers = friendIds.length ? await prisma.user .findMany({ where: { id: { in: friendIds } }, select: { id: true, username: true, look: true }, }) .catch(() => []) : []; const friendById = new Map(friendUsers.map((f) => [f.id, f])); const friends = friendships .map((f) => friendById.get(f.userTwoId)) .filter((f): f is NonNullable => Boolean(f)); // Rooms owned by this user. const rooms = await prisma.rooms .findMany({ where: { ownerId: user.id }, orderBy: { users: "desc" }, take: 8, select: { id: true, name: true, users: true, usersMax: true, state: true, }, }) .catch(() => []); const badges = await prisma.usersBadges .findMany({ where: { userId: user.id }, orderBy: { slotId: "asc" }, }) .catch(() => []); const photos = await prisma.cameraWeb .findMany({ where: { userId: user.id }, orderBy: { timestamp: "desc" }, take: 6, }) .catch(() => []); const guestbook = await prisma.websiteUserGuestbooks .findMany({ where: { profileId: user.id }, orderBy: { createdAt: "desc" }, take: 30, }) .catch(() => []); // Resolve author usernames/looks for the guestbook entries in one query. const authorIds = Array.from(new Set(guestbook.map((g) => g.userId))); const authors = authorIds.length ? await prisma.user .findMany({ where: { id: { in: authorIds } }, select: { id: true, username: true, look: true }, }) .catch(() => []) : []; const authorById = new Map(authors.map((a) => [a.id, a])); return (
{/* ── Profile header ──────────────────── */}
{/* eslint-disable-next-line @next/next/no-img-element */} {`${user.username}

{user.username}

{user.motto || {t("noMotto")}}

{/* ── Wallet / currencies ─────────────── */}
{wallet.map((w) => (
{w.icon}
{w.value.toLocaleString()}
{w.label}
))}
{/* ── Friends ──────────────────────── */} {friends.length === 0 ? ( {t("friendsEmpty")} ) : (
{friends.map((f) => ( {/* eslint-disable-next-line @next/next/no-img-element */} {f.username} {f.username} ))}
)}
{/* ── Rooms ────────────────────────── */} {rooms.length === 0 ? ( {t("roomsEmpty")} ) : (
{rooms.map((r) => (
{"🏠"} {r.name || t("roomUntitled")}
{"👤"} {r.users}/{r.usersMax} {r.state}
))}
)}
{/* ── Badges ────────────────────── */} {badges.length === 0 ? ( {t("badgesEmpty")} ) : (
{badges.map((b) => ( /* eslint-disable-next-line @next/next/no-img-element */ {b.badgeCode} ))}
)}
{/* ── Photos ────────────────────── */} {photos.length === 0 ? ( {t("photosEmpty")} ) : (
{photos.map((p) => (
{/* eslint-disable-next-line @next/next/no-img-element */} {`Photo

{formatTimestamp(p.timestamp)}

))}
)}
{/* ── Guestbook ───────────────────── */} {isLoggedIn ? (
{/* Profile owner id drives the write target; the author is taken from the session inside the action, never from this form. */}