300 lines
11 KiB
TypeScript
300 lines
11 KiB
TypeScript
import type { CSSProperties } from "react";
|
|
import Link from "next/link";
|
|
import { redirect } from "next/navigation";
|
|
import CopyReferralButton from "./CopyReferralButton";
|
|
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";
|
|
import { claimReferral } from "@/actions/referral";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
export const metadata = { title: "My dashboard" };
|
|
|
|
type SearchParams = Promise<{ claimed?: string; error?: string }>;
|
|
|
|
/** Inline-styled feedback banner using the theme's CSS variables. */
|
|
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;
|
|
try {
|
|
// --- Account + reward config -----------------------------------------
|
|
const [user, imagerBase, 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"),
|
|
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),
|
|
]);
|
|
|
|
// The session was already validated above; a missing row here is an
|
|
// exceptional state, so surface the error card rather than redirect from
|
|
// inside the try (a redirect() throw would be swallowed by the catch).
|
|
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();
|
|
|
|
// --- Referral tally ---------------------------------------------------
|
|
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);
|
|
|
|
// --- Online friends ---------------------------------------------------
|
|
// Friendships are bidirectional and stored once: the user can be either
|
|
// user_one_id or user_two_id; the OTHER column is the friend.
|
|
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)}`;
|
|
|
|
content = (
|
|
<>
|
|
{/* 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}
|
|
|
|
{/* Header card -------------------------------------------------- */}
|
|
<ContentCard icon="🏠" title={user.username} subtitle={user.motto || "Welcome back!"} />
|
|
|
|
{/* Stat tiles --------------------------------------------------- */}
|
|
<div className="card-grid sm-2 lg-3">
|
|
<StatBlock icon="💰" value={user.credits.toLocaleString()} label="Credits" />
|
|
<StatBlock icon="🎖️" value={`Rank ${user.rank}`} label="Hotel rank" />
|
|
<StatBlock icon="📅" value={registered} label="Registered" />
|
|
</div>
|
|
|
|
{/* Online friends ----------------------------------------------- */}
|
|
<ContentCard
|
|
icon="🟢"
|
|
title="Online friends"
|
|
subtitle={
|
|
onlineFriends.length === 0
|
|
? "None of your friends are online right now"
|
|
: `${onlineFriends.length} of your ${friends.length} friends online`
|
|
}
|
|
action={<Link href="/friends">View all</Link>}
|
|
padded={onlineFriends.length === 0}
|
|
>
|
|
{onlineFriends.length === 0 ? (
|
|
<EmptyState icon="💤">No friends online right now. Check back later!</EmptyState>
|
|
) : (
|
|
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
|
{onlineFriends.map((friend) => {
|
|
const avatar = avatarImageUrl(imagerBase ?? "", friend.look, {
|
|
size: "s",
|
|
headOnly: true,
|
|
});
|
|
return (
|
|
<div
|
|
key={friend.id}
|
|
className="card hover"
|
|
style={{ display: "flex", gap: "0.85rem", alignItems: "center" }}
|
|
>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
className="avatar"
|
|
src={avatar}
|
|
alt={`${friend.username} avatar`}
|
|
width={50}
|
|
height={50}
|
|
/>
|
|
<div style={{ minWidth: 0, flex: 1 }}>
|
|
<h3 style={{ margin: "0 0 0.3rem", fontSize: "1rem" }}>
|
|
<Link href={`/u/${friend.username}`}>{friend.username}</Link>
|
|
</h3>
|
|
<p
|
|
className="muted"
|
|
style={{
|
|
margin: "0 0 0.4rem",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{friend.motto || "No motto"}
|
|
</p>
|
|
<OnlineBadge online />
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</ContentCard>
|
|
|
|
{/* Referral card ------------------------------------------------ */}
|
|
<ContentCard
|
|
icon="🤝"
|
|
title={`Refer a friend (${referralTotal}/${needed})`}
|
|
subtitle="Refer new users and earn in-game rewards"
|
|
>
|
|
<div style={{ display: "grid", gap: "1rem" }}>
|
|
<p style={{ margin: 0 }}>
|
|
{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="muted"
|
|
style={{ display: "block", marginBottom: "0.35rem", fontSize: "0.85rem" }}
|
|
>
|
|
Your referral link
|
|
</label>
|
|
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
|
<input
|
|
id="referral-link"
|
|
type="text"
|
|
readOnly
|
|
defaultValue={referralLink}
|
|
className="input"
|
|
style={{ flex: "1 1 240px", minWidth: 0 }}
|
|
/>
|
|
<CopyReferralButton value={referralLink} />
|
|
</div>
|
|
</div>
|
|
|
|
{canClaim ? (
|
|
<form action={claimReferral}>
|
|
<button type="submit" className="btn btn-primary" style={{ width: "100%" }}>
|
|
Claim your referral reward!
|
|
</button>
|
|
</form>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
disabled
|
|
className="btn"
|
|
style={{ width: "100%", opacity: 0.6, cursor: "not-allowed" }}
|
|
>
|
|
{`Refer ${remaining} more ${remaining === 1 ? "user" : "users"} to unlock your reward`}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</ContentCard>
|
|
</>
|
|
);
|
|
} catch {
|
|
content = (
|
|
<ContentCard icon="⚠️" title="My dashboard">
|
|
<EmptyState icon="⚠️">
|
|
We couldn't load your dashboard right now. Please try again shortly.
|
|
</EmptyState>
|
|
</ContentCard>
|
|
);
|
|
}
|
|
|
|
return <main style={{ display: "grid", gap: "1.5rem" }}>{content}</main>;
|
|
}
|