From 1ffd46713ad096e301caa1256de4299c12e90209 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 22 Jul 2026 14:42:01 +0200 Subject: [PATCH] Redesign login, register, me pages with premium layout and i18n --- src/app/(site)/login/page.tsx | 179 ++++++++++++- src/app/(site)/me/CopyReferralButton.tsx | 17 +- src/app/(site)/me/page.tsx | 309 +++++++++++++++-------- src/app/(site)/page.tsx | 127 +++++++--- src/app/(site)/register/page.tsx | 26 +- src/components/auth/login-form.tsx | 223 ++++++++-------- src/messages/en.json | 19 +- 7 files changed, 621 insertions(+), 279 deletions(-) diff --git a/src/app/(site)/login/page.tsx b/src/app/(site)/login/page.tsx index 2447e2d7..50957614 100644 --- a/src/app/(site)/login/page.tsx +++ b/src/app/(site)/login/page.tsx @@ -1,19 +1,180 @@ import { headers } from "next/headers"; +import Image from "next/image"; +import Link from "next/link"; +import { getTranslations } from "next-intl/server"; import { LoginForm } from "@/components/auth/login-form"; +import { cached } from "@/lib/cache"; +import { avatarImageUrl } from "@/lib/format"; +import { prisma } from "@/lib/prisma"; import { captchaConfig } from "@/lib/services/captcha"; +import { siteSettings } from "@/lib/services/site-settings"; export default async function LoginPage() { - const cfg = await captchaConfig(); + const t = await getTranslations("pages.login"); + const th = await getTranslations("header"); + const [hotelName, imagerRaw, cfg] = await Promise.all([ + siteSettings.get("hotel_name", "Hotel"), + siteSettings.get( + "habbo_imaging_url", + "https://www.habbo.com/habbo-imaging/avatarimage", + ), + captchaConfig(), + ]); + const imager = imagerRaw ?? "https://www.habbo.com/habbo-imaging/avatarimage"; const nonce = (await headers()).get("x-nonce") ?? undefined; + const [online, recentUsers] = await Promise.all([ + cached("online_count", 10_000, () => + prisma.user.count({ where: { online: "1" } }), + ).catch(() => 0), + prisma.user + .findMany({ + where: { online: "1" }, + select: { username: true, look: true }, + take: 8, + }) + .catch(() => []), + ]); + return ( - +
+ {/* Left panel */} +
+
+ Frank +
+ + {th("online", { count: online, hotel: "" }).trim()} +
+

+ {t("welcomeBack")} +

+

+ {t("welcomeBackSub", { hotelName: hotelName ?? "Hotel" })} +

+
+ + {recentUsers.length > 0 && ( +
+

+ {th("online", { count: online, hotel: "" }).trim()} +

+
+ {recentUsers.map((u) => ( +
+ + + {u.username} + +
+ ))} +
+
+ )} + +

+ No account?{" "} + + Create one + +

+
+ + {/* Right: form */} +
+
+

+ {t("title")} +

+

+ {t("subtitle")} +

+
+ +
+
); } diff --git a/src/app/(site)/me/CopyReferralButton.tsx b/src/app/(site)/me/CopyReferralButton.tsx index b1003436..87283a57 100644 --- a/src/app/(site)/me/CopyReferralButton.tsx +++ b/src/app/(site)/me/CopyReferralButton.tsx @@ -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 ( - ); diff --git a/src/app/(site)/me/page.tsx b/src/app/(site)/me/page.tsx index 4a42e8a5..7c0ecd20 100644 --- a/src/app/(site)/me/page.tsx +++ b/src/app/(site)/me/page.tsx @@ -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 --------------------------------------------- */} +
+ {/* Feedback banners */} {claimed ? (
- Woah! You have successfully claimed your reward โ€” keep up the good - work! + Woah! You have successfully claimed your reward โ€” keep up the good work!
) : null} {error ? ( @@ -196,129 +186,222 @@ export default async function MePage({
) : null} - {/* Header card -------------------------------------------------- */} - - - {/* Stat tiles --------------------------------------------------- */} -
- - - + {/* โ”€โ”€ Profile header โ”€โ”€ */} +
+
+
+ {`${user.username} +
+
+
+ Rank {user.rank} +
+

+ {user.username} +

+

+ {user.motto || "No motto"} +

+
- {/* Online friends ----------------------------------------------- */} - View all} - padded={onlineFriends.length === 0} + {/* โ”€โ”€ Stat tiles โ”€โ”€ */} +
+ {[ + { value: user.credits.toLocaleString(), label: "Credits" }, + { value: `Rank ${user.rank}`, label: "Hotel rank" }, + { value: registered, label: "Registered" }, + ].map((s) => ( +
+
+ {s.value} +
+
+ {s.label} +
+
+ ))} +
+ + {/* โ”€โ”€ Online friends โ”€โ”€ */} +
+
+
+

+ Online friends +

+

+ {onlineFriends.length === 0 + ? "None of your friends are online right now" + : `${onlineFriends.length} of your ${friends.length} friends online`} +

+
+ + View all + +
{onlineFriends.length === 0 ? ( - - No friends online right now. Check back later! - +
+

+ No friends online right now. Check back later! +

+
) : ( -
+
{onlineFriends.map((friend) => { - const avatar = avatarImageUrl(imagerBase ?? "", friend.look, { + const avatar = avatarImageUrl(imager, friend.look, { size: "s", headOnly: true, }); return ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {`${friend.username} -
-

- - {friend.username} - -

+

+ {friend.username} +

+

{friend.motto || "No motto"}

- + + + Online +
-
+ ); })}
)} - +
- {/* Referral card ------------------------------------------------ */} - -
-

+

+

+ Refer a friend ({referralTotal}/{needed}) +

+

+ Refer new users and earn in-game rewards +

+
+
+

{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!`}

- โš ๏ธ 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.
-
+
@@ -328,8 +411,12 @@ export default async function MePage({
@@ -338,26 +425,34 @@ export default async function MePage({ )}
- - +
+
); } catch { content = ( - - - We couldn't load your dashboard right now. Please try again - shortly. - - +
+

+ We couldn't load your dashboard right now. Please try again shortly. +

+
); } - return
{content}
; + return
{content}
; } diff --git a/src/app/(site)/page.tsx b/src/app/(site)/page.tsx index bed43af6..85608daa 100644 --- a/src/app/(site)/page.tsx +++ b/src/app/(site)/page.tsx @@ -33,7 +33,7 @@ async function getHotelData() { .findMany({ where: { online: "1" }, select: { username: true, look: true }, - take: 12, + take: 30, }) .catch(() => []), prisma.cameraWeb @@ -54,7 +54,8 @@ async function getHotelData() { } export default async function Home() { - const t = await getTranslations("header"); + const tp = await getTranslations("pages.home"); + const th = await getTranslations("header"); const { hotelName, imager, @@ -68,6 +69,21 @@ export default async function Home() { return (
+ {/* โ”€โ”€ 1. Welcome section โ”€โ”€ */}
@@ -82,20 +98,13 @@ export default async function Home() { }} > - {online.toLocaleString()} online + {th("online", { count: online, hotel: hotelName ?? "Hotel" })}

- Welcome to{" "} - - {hotelName} - + {tp("welcome", { hotelName: hotelName ?? "Hotel" })}

- {t("tagline")} + {th("tagline")}

- Enter {hotelName} + {tp("enterHotel", { hotelName: hotelName ?? "Hotel" })} - Play now + {th("nitroClient")}
@@ -181,7 +190,7 @@ export default async function Home() { {u.username}

- Online + {tp("statsOnline")}

@@ -193,9 +202,9 @@ export default async function Home() { {/* โ”€โ”€ 2. Stats โ”€โ”€ */}
{[ - { value: online.toLocaleString(), label: "Online now", icon: "๐ŸŸข" }, - { value: users.toLocaleString(), label: "Total users", icon: "๐Ÿ‘ค" }, - { value: rooms.toLocaleString(), label: "Rooms", icon: "๐Ÿ " }, + { value: online.toLocaleString(), label: tp("statsOnline"), icon: "๐ŸŸข" }, + { value: users.toLocaleString(), label: tp("statsCitizens"), icon: "๐Ÿ‘ค" }, + { value: rooms.toLocaleString(), label: tp("statsRooms"), icon: "๐Ÿ " }, ].map((s) => (
- {/* โ”€โ”€ 3. Features โ”€โ”€ */} + {/* โ”€โ”€ 3. User train โ”€โ”€ */} + {recentUsers.length > 0 && ( +
+
+ {[...recentUsers, ...recentUsers].map((u, i) => ( +
+ {u.username} + + {u.username} + +
+ ))} +
+
+ )} + + {/* โ”€โ”€ 4. Features โ”€โ”€ */}
{[ { img: "/assets/images/icons/star.gif", - title: "Avatar", - desc: "Create your unique look with thousands of outfits", + title: tp("featuresAvatar"), + desc: tp("featuresAvatarDesc"), }, { img: "/assets/images/icons/catalog.png", - title: "Furniture", - desc: "Decorate rooms with hundreds of items", + title: tp("featuresFurniture"), + desc: tp("featuresFurnitureDesc"), }, { img: "/assets/images/icons/navigation/community.png", - title: "Community", - desc: "Make friends and join groups", + title: tp("featuresCommunity"), + desc: tp("featuresCommunityDesc"), }, { img: "/assets/images/icons/navigation/bb.png", - title: "Games", - desc: "Compete in battles and events", + title: tp("featuresGames"), + desc: tp("featuresGamesDesc"), }, ].map((f) => (
- {/* โ”€โ”€ 4. News + Sidebar โ”€โ”€ */} + {/* โ”€โ”€ 5. News + Sidebar โ”€โ”€ */}
@@ -288,14 +339,14 @@ export default async function Home() { className="text-xl font-black tracking-tight" style={{ color: "var(--color-text-readable)" }} > - News + {tp("latestNews")} - View all โ†’ + {tp("allNews")}
{articles.length > 0 ? ( @@ -349,7 +400,7 @@ export default async function Home() {
) : (

- No news yet. + {tp("noArticles")}

)}
@@ -369,7 +420,7 @@ export default async function Home() { style={{ color: "var(--color-text-muted)" }} > - Online now + {tp("recentUsers")}
{recentUsers.slice(0, 9).map((u) => ( @@ -418,7 +469,7 @@ export default async function Home() { className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--color-text-muted)" }} > - Photos + {tp("recentPhotos")}
{recentPhotos.map((p) => ( @@ -461,13 +512,13 @@ export default async function Home() { className="text-xs font-bold" style={{ color: "var(--color-text-muted)" }} > - See you inside! + {tp("joinNow", { hotelName: hotelName ?? "Hotel" })}

- {/* โ”€โ”€ 5. CTA โ”€โ”€ */} + {/* โ”€โ”€ 6. CTA โ”€โ”€ */}
- Ready to join? + {tp("joinNow", { hotelName: hotelName ?? "Hotel" })}

- Create your free account and start your adventure today. + {tp("welcomeBody")}

- Create free account + {tp("register")}
diff --git a/src/app/(site)/register/page.tsx b/src/app/(site)/register/page.tsx index fa802f56..ce066d36 100644 --- a/src/app/(site)/register/page.tsx +++ b/src/app/(site)/register/page.tsx @@ -10,7 +10,8 @@ import { captchaConfig } from "@/lib/services/captcha"; import { siteSettings } from "@/lib/services/site-settings"; export default async function RegisterPage() { - const t = await getTranslations("header"); + const th = await getTranslations("header"); + const tpr = await getTranslations("pages.register"); const [hotelName, imagerRaw, cfg] = await Promise.all([ siteSettings.get("hotel_name", "Hotel"), siteSettings.get( @@ -67,26 +68,19 @@ export default async function RegisterPage() { }} > - {online.toLocaleString()} online + {tpr("usersOnline", { count: online })}

- Join{" "} - - {hotelName} - + {tpr("title")} โ€” {hotelName}

- {t("tagline")} + {th("tagline")}

@@ -103,7 +97,7 @@ export default async function RegisterPage() { className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--color-text-muted)" }} > - Who's online + {tpr("whoIsOnline")}
{recentUsers.map((u) => ( @@ -143,7 +137,7 @@ export default async function RegisterPage() { className="text-xs text-center" style={{ color: "var(--color-text-muted)" }} > - Already have an account?{" "} + {tpr("alreadyHaveAccountPre")}{" "} - Sign in + {tpr("alreadyHaveAccountLink")}

@@ -163,13 +157,13 @@ export default async function RegisterPage() { className="text-2xl font-black tracking-tight" style={{ color: "var(--color-text-readable)" }} > - Create your account + {tpr("title")}

- Free and takes less than a minute + {tpr("subtitle")}

- - + <> + {needs2fa && ( +

+ {t("subtitle2fa")} +

+ )} + + setUsername(e.target.value)} + placeholder={t("usernamePlaceholder")} + autoComplete="username" + disabled={needs2fa} + className="w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none" + style={{ + backgroundColor: "var(--color-background)", + color: "var(--color-text-readable, var(--color-text))", + borderColor: needs2fa + ? "color-mix(in srgb, var(--color-text-muted) 15%, transparent)" + : "color-mix(in srgb, var(--color-text-muted) 25%, transparent)", + }} + /> + setPassword(e.target.value)} + placeholder={t("passwordPlaceholder")} + autoComplete="current-password" + disabled={needs2fa} + className="w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none" + style={{ + backgroundColor: "var(--color-background)", + color: "var(--color-text-readable, var(--color-text))", + borderColor: needs2fa + ? "color-mix(in srgb, var(--color-text-muted) 15%, transparent)" + : "color-mix(in srgb, var(--color-text-muted) 25%, transparent)", + }} + /> + {needs2fa ? ( setUsername(e.target.value)} - placeholder={t("usernamePlaceholder")} - autoComplete="username" - disabled={needs2fa} - /> - setPassword(e.target.value)} - placeholder={t("passwordPlaceholder")} - autoComplete="current-password" - disabled={needs2fa} - /> - {needs2fa ? ( - setCode(e.target.value)} - placeholder={t("codePlaceholder")} - inputMode="numeric" - autoComplete="one-time-code" - // biome-ignore lint/a11y/noAutofocus: focus the only relevant field in the 2FA step - autoFocus - /> - ) : ( - - )} - - - - {!needs2fa ? ( - <> -
- - {t("or")} - -
-
- - -
- - ) : null} - - {error ? ( -

setCode(e.target.value)} + placeholder={t("codePlaceholder")} + inputMode="numeric" + autoComplete="one-time-code" + autoFocus + className="w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none" style={{ - color: "var(--color-danger)", - textAlign: "center", - marginBottom: 0, + backgroundColor: "var(--color-background)", + color: "var(--color-text-readable, var(--color-text))", + borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)", }} - > + /> + ) : ( + + )} + + {error && ( +

{error}

- ) : null} -

- {t("noAccount")} {t("createOne")} ยท{" "} - {t("forgotPassword")} -

-
- + {pending ? t("pleaseWait") : needs2fa ? t("verify") : t("signIn")} + + + + {!needs2fa && ( + <> +
+ + + {t("or")} + + +
+
+ + +
+ + )} + +

+ {t("noAccount")}{" "} + + {t("createOne")} + {" "} + ยท{" "} + + {t("forgotPassword")} + +

+ ); } diff --git a/src/messages/en.json b/src/messages/en.json index 5089e7e0..c9f41961 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -234,7 +234,18 @@ "noArticles": "No articles yet.", "noPhotos": "No photos yet.", "noUsers": "No users online", - "latestArticle": "Latest Article" + "latestArticle": "Latest Article", + "statsOnline": "Online", + "statsCitizens": "Citizens", + "statsRooms": "Rooms", + "featuresAvatar": "Avatar", + "featuresAvatarDesc": "Create your unique look", + "featuresFurniture": "Furniture", + "featuresFurnitureDesc": "Decorate rooms with items", + "featuresCommunity": "Community", + "featuresCommunityDesc": "Make friends and chat", + "featuresGames": "Games", + "featuresGamesDesc": "Compete and have fun" }, "news": { "title": "News", @@ -894,6 +905,8 @@ "title": "Sign in", "subtitle": "Welcome back โ€” log in to enter the hotel.", "subtitle2fa": "Enter the 6-digit code from your authenticator.", + "welcomeBack": "Welcome back", + "welcomeBackSub": "Sign in to continue to {hotelName}", "usernamePlaceholder": "Username", "passwordPlaceholder": "Password", "codePlaceholder": "2FA code", @@ -927,7 +940,11 @@ "createAccount": "Create account", "creatingAccount": "Creating account...", "alreadyHaveAccount": "Already have an account? Login!", + "alreadyHaveAccountPre": "Already have an account?", + "alreadyHaveAccountLink": "Sign in", "register": "Register", + "whoIsOnline": "Who's online", + "usersOnline": "{count} online", "termsError": "You must accept the terms & rules to register.", "usernameError": "Username is required.", "emailError": "Valid email is required.",