Files
EpicNext-Cms/src/app/(site)/me/page.tsx
T
2026-07-26 12:53:14 +02:00

512 lines
16 KiB
TypeScript

import Image from "next/image";
import Link from "next/link";
import { redirect } from "next/navigation";
import type { CSSProperties, ReactNode } from "react";
import { claimReferral } from "@/actions/referral";
import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
import { Reveal } from "@/components/motion-reveal";
import { auth } from "@/lib/auth";
import { avatarImageUrl } from "@/lib/format";
import { resolveHotelName } from "@/lib/hotel-name";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
import CopyReferralButton from "./CopyReferralButton";
export const dynamic = "force-dynamic";
export const metadata = { title: "My dashboard" };
type SearchParams = Promise<{ claimed?: string; error?: string }>;
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 {
if (error === "not_enough") return ERROR_MESSAGES.not_enough;
if (error === "no_referrals") return ERROR_MESSAGES.no_referrals;
if (error === "bad_config") return ERROR_MESSAGES.bad_config;
return ERROR_MESSAGES.error;
}
export default async function MePage({
searchParams,
}: {
searchParams: SearchParams;
}) {
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 { claimed, error } = await searchParams;
let content: ReactNode;
try {
const [user, imagerBase, hotelName, neededRaw, amountRaw, currencyRaw] =
await Promise.all([
prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
username: true,
look: true,
motto: true,
rank: true,
credits: true,
accountCreated: true,
},
}),
siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
),
resolveHotelName(),
prisma.websiteSetting
.findUnique({
where: { key: "referrals_needed" },
select: { value: true },
})
.catch(() => null),
prisma.websiteSetting
.findUnique({
where: { key: "referral_reward_amount" },
select: { value: true },
})
.catch(() => null),
prisma.websiteSetting
.findFirst({
where: {
key: {
in: [
"referral_reward_currency_type",
"referral_reward_currency",
],
},
},
select: { value: true },
})
.catch(() => null),
]);
if (!user) throw new Error("user-not-found");
const needed = Number.parseInt(neededRaw?.value ?? "5", 10) || 5;
const rewardAmount = Number.parseInt(amountRaw?.value ?? "30", 10) || 0;
const rewardCurrency = (currencyRaw?.value ?? "diamonds")
.trim()
.toLowerCase();
const referrals = await prisma.userReferrals
.findFirst({
where: { userId },
select: { referralsTotal: true },
orderBy: { id: "desc" },
})
.catch(() => null);
const referralTotal = referrals ? Number(referrals.referralsTotal) : 0;
const canClaim = referralTotal >= needed;
const remaining = Math.max(0, needed - referralTotal);
const friendships = await prisma.messengerFriendships
.findMany({
where: { OR: [{ userOneId: userId }, { userTwoId: userId }] },
select: { userOneId: true, userTwoId: true },
})
.catch(() => []);
const friendIds = Array.from(
new Set(
friendships
.map((f) => (f.userOneId === userId ? f.userTwoId : f.userOneId))
.filter((id) => id && id !== userId),
),
);
const friends = friendIds.length
? await prisma.user
.findMany({
where: { id: { in: friendIds } },
select: {
id: true,
username: true,
look: true,
motto: true,
online: true,
},
})
.catch(() => [])
: [];
const onlineFriends = friends.filter((f) => f.online === "1");
const registered = new Date(user.accountCreated * 1000)
.toISOString()
.slice(0, 10);
const referralLink = `/register?ref=${encodeURIComponent(user.username)}`;
const imager = imagerBase ?? "https://www.habbo.com/habbo-imaging/avatarimage";
const avatarFull = avatarImageUrl(imager, user.look, { size: "l", direction: 2 });
const avatarHead = avatarImageUrl(imager, user.look, { headOnly: true, direction: 2, size: "s" });
content = (
<div className="flex flex-col gap-8 pb-8">
{/* Feedback banners */}
{claimed ? (
<div role="status" style={feedbackStyle("success")}>
Woah! You have successfully claimed your reward — keep up the good work!
</div>
) : null}
{error ? (
<div role="alert" style={feedbackStyle("error")}>
{getErrorMessage(error)}
</div>
) : null}
{/* ── Profile header ── */}
<Reveal>
<div
className="rounded-2xl border p-6 flex flex-col sm:flex-row items-center sm:items-start gap-6"
style={{
background: `linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, var(--color-surface)), var(--color-surface))`,
borderColor: "color-mix(in srgb, var(--color-primary) 20%, transparent)",
}}
>
<div className="relative shrink-0 flex flex-col items-center gap-3">
<div
className="absolute -inset-3 rounded-full opacity-[0.15] blur-[40px]"
style={{ background: "var(--color-primary)" }}
/>
<Image
src={avatarFull}
alt={`${user.username} avatar`}
width={120}
height={160}
className="object-contain drop-shadow-xl relative"
unoptimized
priority
/>
<Link
href="/client"
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"
style={{
background: "var(--color-primary)",
color: "var(--color-primary-foreground)",
borderColor: "color-mix(in srgb, var(--color-primary) 20%, transparent)",
boxShadow: "0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)",
}}
>
<Image
src="/assets/images/EnterHubbly.png"
alt=""
width={16}
height={16}
unoptimized
/>
Enter {hotelName}
</Link>
</div>
<div className="flex-1 text-center sm:text-left">
<div
className="inline-flex items-center gap-2 px-3.5 py-1 rounded-full text-xs font-bold uppercase tracking-wider mb-3 border"
style={{
borderColor: "color-mix(in srgb, var(--color-primary) 18%, transparent)",
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>
</div>
</Reveal>
{/* ── Enter hotel ── */}
<Reveal delay={0.05}>
<Link
href="/client"
className="btn-shine flex items-center justify-center gap-3 rounded-2xl border py-4 px-6 font-extrabold text-base transition-all duration-200 hover:scale-[1.02] active:scale-95 shadow-lg"
style={{
background: "var(--color-primary)",
color: "var(--color-primary-foreground)",
borderColor: "color-mix(in srgb, var(--color-primary) 20%, transparent)",
boxShadow: "0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)",
}}
>
<Image
src="/assets/images/EnterHubbly.png"
alt=""
width={22}
height={22}
unoptimized
/>
Enter {hotelName}
</Link>
</Reveal>
{/* ── Stat tiles ── */}
<Reveal delay={0.1}>
<div className="grid grid-cols-3 gap-4 stagger-children">
{[
{ value: user.credits.toLocaleString(), label: "Credits" },
{ value: `Rank ${user.rank}`, label: "Hotel rank" },
{ value: registered, label: "Registered" },
].map((s) => (
<div
key={s.label}
className="rounded-2xl border p-5 text-center transition-all duration-200 hover:-translate-y-0.5"
style={{
background: "var(--color-surface)",
borderColor: "color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
}}
>
<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-xs font-bold uppercase tracking-widest"
style={{ color: "var(--color-text-muted)" }}
>
{s.label}
</div>
</div>
))}
</div>
</Reveal>
{/* ── Online friends ── */}
<Reveal delay={0.15}>
<div
className="rounded-2xl border p-5"
style={{
background: "var(--color-surface)",
borderColor: "color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
}}
>
<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 ? (
<div className="text-center py-6">
<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">
{onlineFriends.map((friend) => {
const avatar = avatarImageUrl(imager, friend.look, {
size: "s",
headOnly: true,
});
return (
<Link
key={friend.id}
href={`/u/${friend.username}`}
className="flex items-center gap-3 p-3 rounded-xl transition-all duration-200 hover:-translate-y-0.5"
style={{
background: "color-mix(in srgb, var(--color-primary) 4%, transparent)",
textDecoration: "none",
}}
>
<Image
src={avatar}
alt={`${friend.username} avatar`}
width={40}
height={50}
className="rounded-lg shrink-0"
unoptimized
/>
<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>
</div>
</Link>
);
})}
</div>
)}
</div>
</Reveal>
{/* ── Referral card ── */}
<Reveal delay={0.2}>
<div
className="rounded-2xl border p-5"
style={{
background: "var(--color-surface)",
borderColor: "color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
}}
>
<div className="mb-4">
<h2
className="text-lg font-black tracking-tight"
style={{ color: "var(--color-text-readable)" }}
>
Refer a friend ({referralTotal}/{needed})
</h2>
<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>
</div>
</Reveal>
</div>
);
} catch {
content = (
<div
className="rounded-2xl border p-6 text-center"
style={{
background: "var(--color-surface)",
borderColor: "color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
}}
>
<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>
</div>
);
}
return <main>{content}</main>;
}