Redesign login, register, me pages with premium layout and i18n
This commit is contained in:
1 parent
063ac9ec40
commit
1ffd46713a
7 files changed
+621
-279
No files matched your search
@@ -2,11 +2,6 @@
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
/**
|
||||
* Copies the user's referral link to the clipboard. Mirrors AtomCMS's
|
||||
* copyCode() helper on the /me page, but resolves the link to an absolute URL
|
||||
* (the server only knows a relative path, so we prepend the current origin).
|
||||
*/
|
||||
export default function CopyReferralButton({ value }: { value: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
@@ -18,7 +13,6 @@ export default function CopyReferralButton({ value }: { value: string }) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(absolute);
|
||||
} catch {
|
||||
// Older browsers / insecure contexts: fall back to a hidden textarea.
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = absolute;
|
||||
ta.style.position = "fixed";
|
||||
@@ -37,7 +31,16 @@ export default function CopyReferralButton({ value }: { value: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" className="btn btn-secondary" onClick={copy}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
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"
|
||||
style={{
|
||||
background: "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>
|
||||
);
|
||||
|
||||
+202
-107
@@ -1,13 +1,9 @@
|
||||
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,
|
||||
StatBlock,
|
||||
} from "@/components/public/ui";
|
||||
import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -20,7 +16,6 @@ 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"
|
||||
@@ -72,7 +67,6 @@ export default async function MePage({
|
||||
|
||||
let content: ReactNode;
|
||||
try {
|
||||
// --- Account + reward config -----------------------------------------
|
||||
const [user, imagerBase, neededRaw, amountRaw, currencyRaw] =
|
||||
await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
@@ -118,9 +112,6 @@ export default async function MePage({
|
||||
.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;
|
||||
@@ -129,7 +120,6 @@ export default async function MePage({
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
// --- Referral tally ---------------------------------------------------
|
||||
const referrals = await prisma.userReferrals
|
||||
.findFirst({
|
||||
where: { userId },
|
||||
@@ -141,9 +131,6 @@ export default async function MePage({
|
||||
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 }] },
|
||||
@@ -181,13 +168,16 @@ export default async function MePage({
|
||||
.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 = (
|
||||
<>
|
||||
{/* Feedback banners --------------------------------------------- */}
|
||||
<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!
|
||||
Woah! You have successfully claimed your reward — keep up the good work!
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
@@ -196,129 +186,222 @@ export default async function MePage({
|
||||
</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" />
|
||||
{/* ── Profile header ── */}
|
||||
<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">
|
||||
<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
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* 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}
|
||||
{/* ── Stat tiles ── */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[
|
||||
{ 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>
|
||||
|
||||
{/* ── Online friends ── */}
|
||||
<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 ? (
|
||||
<EmptyState icon="💤">
|
||||
No friends online right now. Check back later!
|
||||
</EmptyState>
|
||||
<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="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{onlineFriends.map((friend) => {
|
||||
const avatar = avatarImageUrl(imagerBase ?? "", friend.look, {
|
||||
const avatar = avatarImageUrl(imager, friend.look, {
|
||||
size: "s",
|
||||
headOnly: true,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
<Link
|
||||
key={friend.id}
|
||||
className="card hover"
|
||||
href={`/u/${friend.username}`}
|
||||
className="flex items-center gap-3 p-3 rounded-xl transition-all duration-200 hover:-translate-y-0.5"
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.85rem",
|
||||
alignItems: "center",
|
||||
background: "color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
className="avatar"
|
||||
<Image
|
||||
src={avatar}
|
||||
alt={`${friend.username} avatar`}
|
||||
width={50}
|
||||
width={40}
|
||||
height={50}
|
||||
className="rounded-lg shrink-0"
|
||||
unoptimized
|
||||
/>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<h3 style={{ margin: "0 0 0.3rem", fontSize: "1rem" }}>
|
||||
<Link href={`/u/${friend.username}`}>
|
||||
{friend.username}
|
||||
</Link>
|
||||
</h3>
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className="muted"
|
||||
style={{
|
||||
margin: "0 0 0.4rem",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
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>
|
||||
<OnlineBadge online />
|
||||
<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>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</div>
|
||||
|
||||
{/* Referral card ------------------------------------------------ */}
|
||||
<ContentCard
|
||||
icon="🤝"
|
||||
title={`Refer a friend (${referralTotal}/${needed})`}
|
||||
subtitle="Refer new users and earn in-game rewards"
|
||||
{/* ── Referral card ── */}
|
||||
<div
|
||||
className="rounded-2xl border p-5"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "grid", gap: "1rem" }}>
|
||||
<p style={{ margin: 0 }}>
|
||||
<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.
|
||||
⚠️ 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",
|
||||
}}
|
||||
className="text-xs font-bold mb-1.5 block"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Your referral link
|
||||
</label>
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<input
|
||||
id="referral-link"
|
||||
type="text"
|
||||
readOnly
|
||||
defaultValue={referralLink}
|
||||
className="input"
|
||||
style={{ flex: "1 1 240px", minWidth: 0 }}
|
||||
className="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>
|
||||
@@ -328,8 +411,12 @@ export default async function MePage({
|
||||
<form action={claimReferral}>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
style={{ width: "100%" }}
|
||||
className="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>
|
||||
@@ -338,26 +425,34 @@ export default async function MePage({
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="btn"
|
||||
style={{ width: "100%", opacity: 0.6, cursor: "not-allowed" }}
|
||||
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>
|
||||
</ContentCard>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} catch {
|
||||
content = (
|
||||
<ContentCard icon="⚠️" title="My dashboard">
|
||||
<EmptyState icon="⚠️">
|
||||
We couldn't load your dashboard right now. Please try again
|
||||
shortly.
|
||||
</EmptyState>
|
||||
</ContentCard>
|
||||
<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't load your dashboard right now. Please try again shortly.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <main style={{ display: "grid", gap: "1.5rem" }}>{content}</main>;
|
||||
return <main>{content}</main>;
|
||||
}
|
||||
Reference in new issue
Block a user