feat(me): organize personal dashboard and localize user actions
This commit is contained in:
1 parent
2840ef5d9d
commit
85a6df162b
9 files changed
+768
-576
No files matched your search
@@ -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>}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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} · {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't load your dashboard right now. Please try again
|
|
||||||
shortly.
|
|
||||||
</p>
|
|
||||||
</SurfaceCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <main>{content}</main>;
|
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
File renamed without changes.
@@ -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([]);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
Reference in new issue
Block a user