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
@@ -1,19 +1,180 @@
|
|||||||
import { headers } from "next/headers";
|
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 { 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 { captchaConfig } from "@/lib/services/captcha";
|
||||||
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
export default async function LoginPage() {
|
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 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 (
|
return (
|
||||||
<LoginForm
|
<div className="flex flex-col lg:flex-row gap-10">
|
||||||
captcha={{
|
{/* Left panel */}
|
||||||
provider: cfg.provider,
|
<div className="lg:w-80 shrink-0 space-y-4">
|
||||||
siteKey: cfg.siteKey || undefined,
|
<div
|
||||||
field: cfg.field || undefined,
|
className="rounded-2xl border p-6 text-center"
|
||||||
}}
|
style={{
|
||||||
nonce={nonce}
|
borderColor:
|
||||||
/>
|
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
||||||
|
background: `linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, var(--color-surface)), var(--color-surface))`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/assets/images/FrankwithBag.gif"
|
||||||
|
alt="Frank"
|
||||||
|
width={130}
|
||||||
|
height={170}
|
||||||
|
className="object-contain mx-auto drop-shadow-xl"
|
||||||
|
unoptimized
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center gap-2 px-3.5 py-1 rounded-full text-xs font-bold uppercase tracking-wider mt-4 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))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="w-2 h-2 rounded-full bg-green-500" />
|
||||||
|
{th("online", { count: online, hotel: "" }).trim()}
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
className="text-xl font-black"
|
||||||
|
style={{ color: "var(--color-text-readable)" }}
|
||||||
|
>
|
||||||
|
{t("welcomeBack")}
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
className="text-xs mt-1 mx-auto max-w-[200px] leading-relaxed"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
{t("welcomeBackSub", { hotelName: hotelName ?? "Hotel" })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{recentUsers.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="rounded-2xl border p-4"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
className="text-xs font-bold uppercase tracking-widest mb-3"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
{th("online", { count: online, hotel: "" }).trim()}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-4 gap-1.5">
|
||||||
|
{recentUsers.map((u) => (
|
||||||
|
<div
|
||||||
|
key={u.username}
|
||||||
|
className="flex flex-col items-center gap-0.5 p-1.5 rounded-lg"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={avatarImageUrl(imager, u.look, {
|
||||||
|
headOnly: true,
|
||||||
|
direction: 2,
|
||||||
|
size: "s",
|
||||||
|
})}
|
||||||
|
alt=""
|
||||||
|
width={26}
|
||||||
|
height={32}
|
||||||
|
className="rounded-md"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="text-[8px] font-bold truncate w-full text-center leading-tight"
|
||||||
|
style={{ color: "var(--color-text-readable)" }}
|
||||||
|
>
|
||||||
|
{u.username}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p
|
||||||
|
className="text-xs text-center"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
No account?{" "}
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="font-extrabold hover:underline"
|
||||||
|
style={{
|
||||||
|
color: "var(--color-primary-readable, var(--color-primary))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create one
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: form */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2
|
||||||
|
className="text-2xl font-black tracking-tight"
|
||||||
|
style={{ color: "var(--color-text-readable)" }}
|
||||||
|
>
|
||||||
|
{t("title")}
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
className="text-sm mt-0.5"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
{t("subtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<LoginForm
|
||||||
|
captcha={{
|
||||||
|
provider: cfg.provider,
|
||||||
|
siteKey: cfg.siteKey || undefined,
|
||||||
|
field: cfg.field || undefined,
|
||||||
|
}}
|
||||||
|
nonce={nonce}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,11 +2,6 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
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 }) {
|
export default function CopyReferralButton({ value }: { value: string }) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
@@ -18,7 +13,6 @@ export default function CopyReferralButton({ value }: { value: string }) {
|
|||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(absolute);
|
await navigator.clipboard.writeText(absolute);
|
||||||
} catch {
|
} catch {
|
||||||
// Older browsers / insecure contexts: fall back to a hidden textarea.
|
|
||||||
const ta = document.createElement("textarea");
|
const ta = document.createElement("textarea");
|
||||||
ta.value = absolute;
|
ta.value = absolute;
|
||||||
ta.style.position = "fixed";
|
ta.style.position = "fixed";
|
||||||
@@ -37,7 +31,16 @@ export default function CopyReferralButton({ value }: { value: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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"}
|
{copied ? "Copied!" : "Copy link"}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
+202
-107
@@ -1,13 +1,9 @@
|
|||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import type { CSSProperties, ReactNode } from "react";
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
import { claimReferral } from "@/actions/referral";
|
import { claimReferral } from "@/actions/referral";
|
||||||
import {
|
import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
|
||||||
ContentCard,
|
|
||||||
EmptyState,
|
|
||||||
OnlineBadge,
|
|
||||||
StatBlock,
|
|
||||||
} from "@/components/public/ui";
|
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { avatarImageUrl } from "@/lib/format";
|
import { avatarImageUrl } from "@/lib/format";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -20,7 +16,6 @@ export const metadata = { title: "My dashboard" };
|
|||||||
|
|
||||||
type SearchParams = Promise<{ claimed?: string; error?: string }>;
|
type SearchParams = Promise<{ claimed?: string; error?: string }>;
|
||||||
|
|
||||||
/** Inline-styled feedback banner using the theme's CSS variables. */
|
|
||||||
function feedbackStyle(tone: "success" | "error" | "warning"): CSSProperties {
|
function feedbackStyle(tone: "success" | "error" | "warning"): CSSProperties {
|
||||||
const accent =
|
const accent =
|
||||||
tone === "error"
|
tone === "error"
|
||||||
@@ -72,7 +67,6 @@ export default async function MePage({
|
|||||||
|
|
||||||
let content: ReactNode;
|
let content: ReactNode;
|
||||||
try {
|
try {
|
||||||
// --- Account + reward config -----------------------------------------
|
|
||||||
const [user, imagerBase, neededRaw, amountRaw, currencyRaw] =
|
const [user, imagerBase, neededRaw, amountRaw, currencyRaw] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
prisma.user.findUnique({
|
prisma.user.findUnique({
|
||||||
@@ -118,9 +112,6 @@ export default async function MePage({
|
|||||||
.catch(() => null),
|
.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");
|
if (!user) throw new Error("user-not-found");
|
||||||
|
|
||||||
const needed = Number.parseInt(neededRaw?.value ?? "5", 10) || 5;
|
const needed = Number.parseInt(neededRaw?.value ?? "5", 10) || 5;
|
||||||
@@ -129,7 +120,6 @@ export default async function MePage({
|
|||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
|
|
||||||
// --- Referral tally ---------------------------------------------------
|
|
||||||
const referrals = await prisma.userReferrals
|
const referrals = await prisma.userReferrals
|
||||||
.findFirst({
|
.findFirst({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
@@ -141,9 +131,6 @@ export default async function MePage({
|
|||||||
const canClaim = referralTotal >= needed;
|
const canClaim = referralTotal >= needed;
|
||||||
const remaining = Math.max(0, needed - referralTotal);
|
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
|
const friendships = await prisma.messengerFriendships
|
||||||
.findMany({
|
.findMany({
|
||||||
where: { OR: [{ userOneId: userId }, { userTwoId: userId }] },
|
where: { OR: [{ userOneId: userId }, { userTwoId: userId }] },
|
||||||
@@ -181,13 +168,16 @@ export default async function MePage({
|
|||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
const referralLink = `/register?ref=${encodeURIComponent(user.username)}`;
|
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 = (
|
content = (
|
||||||
<>
|
<div className="flex flex-col gap-8 pb-8">
|
||||||
{/* Feedback banners --------------------------------------------- */}
|
{/* Feedback banners */}
|
||||||
{claimed ? (
|
{claimed ? (
|
||||||
<div role="status" style={feedbackStyle("success")}>
|
<div role="status" style={feedbackStyle("success")}>
|
||||||
Woah! You have successfully claimed your reward — keep up the good
|
Woah! You have successfully claimed your reward — keep up the good work!
|
||||||
work!
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{error ? (
|
{error ? (
|
||||||
@@ -196,129 +186,222 @@ export default async function MePage({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Header card -------------------------------------------------- */}
|
{/* ── Profile header ── */}
|
||||||
<ContentCard
|
<div
|
||||||
icon="🏠"
|
className="rounded-2xl border p-6 flex flex-col sm:flex-row items-center sm:items-start gap-6"
|
||||||
title={user.username}
|
style={{
|
||||||
subtitle={user.motto || "Welcome back!"}
|
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)",
|
||||||
|
}}
|
||||||
{/* Stat tiles --------------------------------------------------- */}
|
>
|
||||||
<div className="card-grid sm-2 lg-3">
|
<div className="relative shrink-0">
|
||||||
<StatBlock
|
<div
|
||||||
icon="💰"
|
className="absolute -inset-3 rounded-full opacity-[0.15] blur-[40px]"
|
||||||
value={user.credits.toLocaleString()}
|
style={{ background: "var(--color-primary)" }}
|
||||||
label="Credits"
|
/>
|
||||||
/>
|
<Image
|
||||||
<StatBlock icon="🎖️" value={`Rank ${user.rank}`} label="Hotel rank" />
|
src={avatarFull}
|
||||||
<StatBlock icon="📅" value={registered} label="Registered" />
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Online friends ----------------------------------------------- */}
|
{/* ── Stat tiles ── */}
|
||||||
<ContentCard
|
<div className="grid grid-cols-3 gap-4">
|
||||||
icon="🟢"
|
{[
|
||||||
title="Online friends"
|
{ value: user.credits.toLocaleString(), label: "Credits" },
|
||||||
subtitle={
|
{ value: `Rank ${user.rank}`, label: "Hotel rank" },
|
||||||
onlineFriends.length === 0
|
{ value: registered, label: "Registered" },
|
||||||
? "None of your friends are online right now"
|
].map((s) => (
|
||||||
: `${onlineFriends.length} of your ${friends.length} friends online`
|
<div
|
||||||
}
|
key={s.label}
|
||||||
action={<Link href="/friends">View all</Link>}
|
className="rounded-2xl border p-5 text-center transition-all duration-200 hover:-translate-y-0.5"
|
||||||
padded={onlineFriends.length === 0}
|
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 ? (
|
{onlineFriends.length === 0 ? (
|
||||||
<EmptyState icon="💤">
|
<div className="text-center py-6">
|
||||||
No friends online right now. Check back later!
|
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||||
</EmptyState>
|
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) => {
|
{onlineFriends.map((friend) => {
|
||||||
const avatar = avatarImageUrl(imagerBase ?? "", friend.look, {
|
const avatar = avatarImageUrl(imager, friend.look, {
|
||||||
size: "s",
|
size: "s",
|
||||||
headOnly: true,
|
headOnly: true,
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<div
|
<Link
|
||||||
key={friend.id}
|
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={{
|
style={{
|
||||||
display: "flex",
|
background: "color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
||||||
gap: "0.85rem",
|
textDecoration: "none",
|
||||||
alignItems: "center",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
<Image
|
||||||
<img
|
|
||||||
className="avatar"
|
|
||||||
src={avatar}
|
src={avatar}
|
||||||
alt={`${friend.username} avatar`}
|
alt={`${friend.username} avatar`}
|
||||||
width={50}
|
width={40}
|
||||||
height={50}
|
height={50}
|
||||||
|
className="rounded-lg shrink-0"
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
<div style={{ minWidth: 0, flex: 1 }}>
|
<div className="min-w-0">
|
||||||
<h3 style={{ margin: "0 0 0.3rem", fontSize: "1rem" }}>
|
|
||||||
<Link href={`/u/${friend.username}`}>
|
|
||||||
{friend.username}
|
|
||||||
</Link>
|
|
||||||
</h3>
|
|
||||||
<p
|
<p
|
||||||
className="muted"
|
className="text-sm font-extrabold truncate"
|
||||||
style={{
|
style={{ color: "var(--color-text-readable)" }}
|
||||||
margin: "0 0 0.4rem",
|
>
|
||||||
overflow: "hidden",
|
{friend.username}
|
||||||
textOverflow: "ellipsis",
|
</p>
|
||||||
whiteSpace: "nowrap",
|
<p
|
||||||
}}
|
className="text-[10px] font-semibold truncate mt-0.5"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
>
|
>
|
||||||
{friend.motto || "No motto"}
|
{friend.motto || "No motto"}
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ContentCard>
|
</div>
|
||||||
|
|
||||||
{/* Referral card ------------------------------------------------ */}
|
{/* ── Referral card ── */}
|
||||||
<ContentCard
|
<div
|
||||||
icon="🤝"
|
className="rounded-2xl border p-5"
|
||||||
title={`Refer a friend (${referralTotal}/${needed})`}
|
style={{
|
||||||
subtitle="Refer new users and earn in-game rewards"
|
background: "var(--color-surface)",
|
||||||
|
borderColor: "color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: "grid", gap: "1rem" }}>
|
<div className="mb-4">
|
||||||
<p style={{ margin: 0 }}>
|
<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
|
{rewardAmount > 0
|
||||||
? `For every ${needed} users who register through your referral link you can claim a reward of ${rewardAmount.toLocaleString()} ${rewardCurrency}!`
|
? `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!`}
|
: `Invite ${needed} users through your referral link to claim a reward!`}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div role="note" style={feedbackStyle("warning")}>
|
<div role="note" style={feedbackStyle("warning")}>
|
||||||
⚠️ Boosting referrals by making your own accounts will lead to
|
⚠️ Boosting referrals by making your own accounts will lead to removal of all progress, currency, inventory and a potential ban.
|
||||||
removal of all progress, currency, inventory and a potential ban.
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="referral-link"
|
htmlFor="referral-link"
|
||||||
className="muted"
|
className="text-xs font-bold mb-1.5 block"
|
||||||
style={{
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
display: "block",
|
|
||||||
marginBottom: "0.35rem",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Your referral link
|
Your referral link
|
||||||
</label>
|
</label>
|
||||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
<div className="flex gap-2 flex-wrap">
|
||||||
<input
|
<input
|
||||||
id="referral-link"
|
id="referral-link"
|
||||||
type="text"
|
type="text"
|
||||||
readOnly
|
readOnly
|
||||||
defaultValue={referralLink}
|
defaultValue={referralLink}
|
||||||
className="input"
|
className="flex-1 min-w-0 rounded-xl border-2 px-4 py-2.5 text-sm font-medium"
|
||||||
style={{ flex: "1 1 240px", minWidth: 0 }}
|
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} />
|
<CopyReferralButton value={referralLink} />
|
||||||
</div>
|
</div>
|
||||||
@@ -328,8 +411,12 @@ export default async function MePage({
|
|||||||
<form action={claimReferral}>
|
<form action={claimReferral}>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn btn-primary"
|
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={{ width: "100%" }}
|
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!
|
Claim your referral reward!
|
||||||
</button>
|
</button>
|
||||||
@@ -338,26 +425,34 @@ export default async function MePage({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled
|
disabled
|
||||||
className="btn"
|
className="w-full rounded-xl font-extrabold text-sm py-3.5 opacity-60 cursor-not-allowed"
|
||||||
style={{ width: "100%", opacity: 0.6, 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`}
|
{`Refer ${remaining} more ${remaining === 1 ? "user" : "users"} to unlock your reward`}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ContentCard>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
content = (
|
content = (
|
||||||
<ContentCard icon="⚠️" title="My dashboard">
|
<div
|
||||||
<EmptyState icon="⚠️">
|
className="rounded-2xl border p-6 text-center"
|
||||||
We couldn't load your dashboard right now. Please try again
|
style={{
|
||||||
shortly.
|
background: "var(--color-surface)",
|
||||||
</EmptyState>
|
borderColor: "color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
|
||||||
</ContentCard>
|
}}
|
||||||
|
>
|
||||||
|
<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>;
|
||||||
}
|
}
|
||||||
+89
-38
@@ -33,7 +33,7 @@ async function getHotelData() {
|
|||||||
.findMany({
|
.findMany({
|
||||||
where: { online: "1" },
|
where: { online: "1" },
|
||||||
select: { username: true, look: true },
|
select: { username: true, look: true },
|
||||||
take: 12,
|
take: 30,
|
||||||
})
|
})
|
||||||
.catch(() => []),
|
.catch(() => []),
|
||||||
prisma.cameraWeb
|
prisma.cameraWeb
|
||||||
@@ -54,7 +54,8 @@ async function getHotelData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const t = await getTranslations("header");
|
const tp = await getTranslations("pages.home");
|
||||||
|
const th = await getTranslations("header");
|
||||||
const {
|
const {
|
||||||
hotelName,
|
hotelName,
|
||||||
imager,
|
imager,
|
||||||
@@ -68,6 +69,21 @@ export default async function Home() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-16 pb-16">
|
<div className="flex flex-col gap-16 pb-16">
|
||||||
|
<style>{`
|
||||||
|
@keyframes train-scroll {
|
||||||
|
0% { transform: translateX(0); }
|
||||||
|
100% { transform: translateX(-50%); }
|
||||||
|
}
|
||||||
|
.train-track {
|
||||||
|
animation: train-scroll 40s linear infinite;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
.train-track:hover {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
{/* ── 1. Welcome section ── */}
|
{/* ── 1. Welcome section ── */}
|
||||||
<div className="flex flex-col lg:flex-row items-center gap-10 lg:gap-16">
|
<div className="flex flex-col lg:flex-row items-center gap-10 lg:gap-16">
|
||||||
<div className="flex-1 text-center lg:text-left">
|
<div className="flex-1 text-center lg:text-left">
|
||||||
@@ -82,20 +98,13 @@ export default async function Home() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="w-2 h-2 rounded-full bg-green-500" />
|
<span className="w-2 h-2 rounded-full bg-green-500" />
|
||||||
{online.toLocaleString()} online
|
{th("online", { count: online, hotel: hotelName ?? "Hotel" })}
|
||||||
</div>
|
</div>
|
||||||
<h1
|
<h1
|
||||||
className="text-4xl md:text-5xl lg:text-6xl font-black leading-[1.05] tracking-tight mb-4"
|
className="text-4xl md:text-5xl lg:text-6xl font-black leading-[1.05] tracking-tight mb-4"
|
||||||
style={{ color: "var(--color-text-readable)" }}
|
style={{ color: "var(--color-text-readable)" }}
|
||||||
>
|
>
|
||||||
Welcome to{" "}
|
{tp("welcome", { hotelName: hotelName ?? "Hotel" })}
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
color: "var(--color-primary-readable, var(--color-primary))",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{hotelName}
|
|
||||||
</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
<p
|
<p
|
||||||
className="text-base md:text-lg leading-relaxed max-w-xl mb-8"
|
className="text-base md:text-lg leading-relaxed max-w-xl mb-8"
|
||||||
@@ -104,7 +113,7 @@ export default async function Home() {
|
|||||||
"var(--color-text-muted-readable, var(--color-text-muted))",
|
"var(--color-text-muted-readable, var(--color-text-muted))",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("tagline")}
|
{th("tagline")}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-3 justify-center lg:justify-start">
|
<div className="flex flex-wrap gap-3 justify-center lg:justify-start">
|
||||||
<Link
|
<Link
|
||||||
@@ -124,7 +133,7 @@ export default async function Home() {
|
|||||||
height={20}
|
height={20}
|
||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
Enter {hotelName}
|
{tp("enterHotel", { hotelName: hotelName ?? "Hotel" })}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/client"
|
href="/client"
|
||||||
@@ -135,7 +144,7 @@ export default async function Home() {
|
|||||||
color: "var(--color-text-readable)",
|
color: "var(--color-text-readable)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Play now
|
{th("nitroClient")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -181,7 +190,7 @@ export default async function Home() {
|
|||||||
{u.username}
|
{u.username}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[10px] font-semibold text-green-500">
|
<p className="text-[10px] font-semibold text-green-500">
|
||||||
Online
|
{tp("statsOnline")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,9 +202,9 @@ export default async function Home() {
|
|||||||
{/* ── 2. Stats ── */}
|
{/* ── 2. Stats ── */}
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
{[
|
{[
|
||||||
{ value: online.toLocaleString(), label: "Online now", icon: "🟢" },
|
{ value: online.toLocaleString(), label: tp("statsOnline"), icon: "🟢" },
|
||||||
{ value: users.toLocaleString(), label: "Total users", icon: "👤" },
|
{ value: users.toLocaleString(), label: tp("statsCitizens"), icon: "👤" },
|
||||||
{ value: rooms.toLocaleString(), label: "Rooms", icon: "🏠" },
|
{ value: rooms.toLocaleString(), label: tp("statsRooms"), icon: "🏠" },
|
||||||
].map((s) => (
|
].map((s) => (
|
||||||
<div
|
<div
|
||||||
key={s.label}
|
key={s.label}
|
||||||
@@ -222,28 +231,70 @@ export default async function Home() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── 3. Features ── */}
|
{/* ── 3. User train ── */}
|
||||||
|
{recentUsers.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="rounded-2xl border overflow-hidden py-3"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
borderColor: "color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="train-track">
|
||||||
|
{[...recentUsers, ...recentUsers].map((u, i) => (
|
||||||
|
<div
|
||||||
|
key={`${u.username}-${i}`}
|
||||||
|
className="flex flex-col items-center gap-0.5 px-2 py-1.5 rounded-xl transition-all duration-200 hover:scale-110"
|
||||||
|
style={{
|
||||||
|
background: "color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={avatarImageUrl(imager, u.look, {
|
||||||
|
headOnly: true,
|
||||||
|
direction: 2,
|
||||||
|
size: "s",
|
||||||
|
})}
|
||||||
|
alt={u.username}
|
||||||
|
width={34}
|
||||||
|
height={42}
|
||||||
|
className="rounded-md"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="text-[9px] font-bold truncate max-w-[54px] text-center leading-tight"
|
||||||
|
style={{ color: "var(--color-text-readable)" }}
|
||||||
|
>
|
||||||
|
{u.username}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 4. Features ── */}
|
||||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
img: "/assets/images/icons/star.gif",
|
img: "/assets/images/icons/star.gif",
|
||||||
title: "Avatar",
|
title: tp("featuresAvatar"),
|
||||||
desc: "Create your unique look with thousands of outfits",
|
desc: tp("featuresAvatarDesc"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
img: "/assets/images/icons/catalog.png",
|
img: "/assets/images/icons/catalog.png",
|
||||||
title: "Furniture",
|
title: tp("featuresFurniture"),
|
||||||
desc: "Decorate rooms with hundreds of items",
|
desc: tp("featuresFurnitureDesc"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
img: "/assets/images/icons/navigation/community.png",
|
img: "/assets/images/icons/navigation/community.png",
|
||||||
title: "Community",
|
title: tp("featuresCommunity"),
|
||||||
desc: "Make friends and join groups",
|
desc: tp("featuresCommunityDesc"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
img: "/assets/images/icons/navigation/bb.png",
|
img: "/assets/images/icons/navigation/bb.png",
|
||||||
title: "Games",
|
title: tp("featuresGames"),
|
||||||
desc: "Compete in battles and events",
|
desc: tp("featuresGamesDesc"),
|
||||||
},
|
},
|
||||||
].map((f) => (
|
].map((f) => (
|
||||||
<div
|
<div
|
||||||
@@ -280,7 +331,7 @@ export default async function Home() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── 4. News + Sidebar ── */}
|
{/* ── 5. News + Sidebar ── */}
|
||||||
<div className="grid lg:grid-cols-12 gap-6">
|
<div className="grid lg:grid-cols-12 gap-6">
|
||||||
<div className="lg:col-span-8 space-y-4">
|
<div className="lg:col-span-8 space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -288,14 +339,14 @@ export default async function Home() {
|
|||||||
className="text-xl font-black tracking-tight"
|
className="text-xl font-black tracking-tight"
|
||||||
style={{ color: "var(--color-text-readable)" }}
|
style={{ color: "var(--color-text-readable)" }}
|
||||||
>
|
>
|
||||||
News
|
{tp("latestNews")}
|
||||||
</h2>
|
</h2>
|
||||||
<Link
|
<Link
|
||||||
href="/news"
|
href="/news"
|
||||||
className="text-sm font-bold hover:underline"
|
className="text-sm font-bold hover:underline"
|
||||||
style={{ color: "var(--color-primary)" }}
|
style={{ color: "var(--color-primary)" }}
|
||||||
>
|
>
|
||||||
View all →
|
{tp("allNews")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{articles.length > 0 ? (
|
{articles.length > 0 ? (
|
||||||
@@ -349,7 +400,7 @@ export default async function Home() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||||
No news yet.
|
{tp("noArticles")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -369,7 +420,7 @@ export default async function Home() {
|
|||||||
style={{ color: "var(--color-text-muted)" }}
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
>
|
>
|
||||||
<span className="w-2 h-2 rounded-full bg-green-500" />
|
<span className="w-2 h-2 rounded-full bg-green-500" />
|
||||||
Online now
|
{tp("recentUsers")}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{recentUsers.slice(0, 9).map((u) => (
|
{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"
|
className="text-xs font-bold uppercase tracking-widest mb-3"
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
>
|
>
|
||||||
Photos
|
{tp("recentPhotos")}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{recentPhotos.map((p) => (
|
{recentPhotos.map((p) => (
|
||||||
@@ -461,13 +512,13 @@ export default async function Home() {
|
|||||||
className="text-xs font-bold"
|
className="text-xs font-bold"
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
>
|
>
|
||||||
See you inside!
|
{tp("joinNow", { hotelName: hotelName ?? "Hotel" })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── 5. CTA ── */}
|
{/* ── 6. CTA ── */}
|
||||||
<div
|
<div
|
||||||
className="rounded-2xl border text-center px-6 py-10"
|
className="rounded-2xl border text-center px-6 py-10"
|
||||||
style={{
|
style={{
|
||||||
@@ -480,13 +531,13 @@ export default async function Home() {
|
|||||||
className="text-2xl md:text-3xl font-black tracking-tight mb-2"
|
className="text-2xl md:text-3xl font-black tracking-tight mb-2"
|
||||||
style={{ color: "var(--color-text-readable)" }}
|
style={{ color: "var(--color-text-readable)" }}
|
||||||
>
|
>
|
||||||
Ready to join?
|
{tp("joinNow", { hotelName: hotelName ?? "Hotel" })}
|
||||||
</h2>
|
</h2>
|
||||||
<p
|
<p
|
||||||
className="text-sm mb-6 max-w-sm mx-auto"
|
className="text-sm mb-6 max-w-sm mx-auto"
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
>
|
>
|
||||||
Create your free account and start your adventure today.
|
{tp("welcomeBody")}
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/register"
|
href="/register"
|
||||||
@@ -505,7 +556,7 @@ export default async function Home() {
|
|||||||
height={20}
|
height={20}
|
||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
Create free account
|
{tp("register")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import { captchaConfig } from "@/lib/services/captcha";
|
|||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
export default async function RegisterPage() {
|
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([
|
const [hotelName, imagerRaw, cfg] = await Promise.all([
|
||||||
siteSettings.get("hotel_name", "Hotel"),
|
siteSettings.get("hotel_name", "Hotel"),
|
||||||
siteSettings.get(
|
siteSettings.get(
|
||||||
@@ -67,26 +68,19 @@ export default async function RegisterPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="w-2 h-2 rounded-full bg-green-500" />
|
<span className="w-2 h-2 rounded-full bg-green-500" />
|
||||||
{online.toLocaleString()} online
|
{tpr("usersOnline", { count: online })}
|
||||||
</div>
|
</div>
|
||||||
<h1
|
<h1
|
||||||
className="text-xl font-black"
|
className="text-xl font-black"
|
||||||
style={{ color: "var(--color-text-readable)" }}
|
style={{ color: "var(--color-text-readable)" }}
|
||||||
>
|
>
|
||||||
Join{" "}
|
{tpr("title")} — {hotelName}
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
color: "var(--color-primary-readable, var(--color-primary))",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{hotelName}
|
|
||||||
</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
<p
|
<p
|
||||||
className="text-xs mt-1 mx-auto max-w-[200px] leading-relaxed"
|
className="text-xs mt-1 mx-auto max-w-[200px] leading-relaxed"
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
>
|
>
|
||||||
{t("tagline")}
|
{th("tagline")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -103,7 +97,7 @@ export default async function RegisterPage() {
|
|||||||
className="text-xs font-bold uppercase tracking-widest mb-3"
|
className="text-xs font-bold uppercase tracking-widest mb-3"
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
>
|
>
|
||||||
Who's online
|
{tpr("whoIsOnline")}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-4 gap-1.5">
|
<div className="grid grid-cols-4 gap-1.5">
|
||||||
{recentUsers.map((u) => (
|
{recentUsers.map((u) => (
|
||||||
@@ -143,7 +137,7 @@ export default async function RegisterPage() {
|
|||||||
className="text-xs text-center"
|
className="text-xs text-center"
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
>
|
>
|
||||||
Already have an account?{" "}
|
{tpr("alreadyHaveAccountPre")}{" "}
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
className="font-extrabold hover:underline"
|
className="font-extrabold hover:underline"
|
||||||
@@ -151,7 +145,7 @@ export default async function RegisterPage() {
|
|||||||
color: "var(--color-primary-readable, var(--color-primary))",
|
color: "var(--color-primary-readable, var(--color-primary))",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Sign in
|
{tpr("alreadyHaveAccountLink")}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -163,13 +157,13 @@ export default async function RegisterPage() {
|
|||||||
className="text-2xl font-black tracking-tight"
|
className="text-2xl font-black tracking-tight"
|
||||||
style={{ color: "var(--color-text-readable)" }}
|
style={{ color: "var(--color-text-readable)" }}
|
||||||
>
|
>
|
||||||
Create your account
|
{tpr("title")}
|
||||||
</h2>
|
</h2>
|
||||||
<p
|
<p
|
||||||
className="text-sm mt-0.5"
|
className="text-sm mt-0.5"
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
>
|
>
|
||||||
Free and takes less than a minute
|
{tpr("subtitle")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<RegisterForm
|
<RegisterForm
|
||||||
|
|||||||
+122
-101
@@ -10,7 +10,6 @@ import {
|
|||||||
type CaptchaPublicConfig,
|
type CaptchaPublicConfig,
|
||||||
readCaptchaToken,
|
readCaptchaToken,
|
||||||
} from "@/components/auth/captcha-widget";
|
} from "@/components/auth/captcha-widget";
|
||||||
import { ContentCard } from "@/components/public/ui";
|
|
||||||
|
|
||||||
export function LoginForm({
|
export function LoginForm({
|
||||||
captcha = { provider: "none" },
|
captcha = { provider: "none" },
|
||||||
@@ -64,117 +63,139 @@ export function LoginForm({
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.location.href = "/";
|
window.location.href = "/me";
|
||||||
} finally {
|
} finally {
|
||||||
setPending(false);
|
setPending(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ maxWidth: 420, margin: "2rem auto" }}>
|
<>
|
||||||
<ContentCard
|
{needs2fa && (
|
||||||
icon={needs2fa ? "🔒" : "🔑"}
|
<p className="text-sm font-semibold mb-4" style={{ color: "var(--color-text-muted)" }}>
|
||||||
title={t("title")}
|
{t("subtitle2fa")}
|
||||||
subtitle={needs2fa ? t("subtitle2fa") : t("subtitle")}
|
</p>
|
||||||
>
|
)}
|
||||||
<form onSubmit={onSubmit} style={{ display: "grid", gap: "0.7rem" }}>
|
<form onSubmit={onSubmit} className="flex flex-col gap-4">
|
||||||
|
<input
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => 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)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => 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 ? (
|
||||||
<input
|
<input
|
||||||
value={username}
|
value={code}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setCode(e.target.value)}
|
||||||
placeholder={t("usernamePlaceholder")}
|
placeholder={t("codePlaceholder")}
|
||||||
autoComplete="username"
|
inputMode="numeric"
|
||||||
disabled={needs2fa}
|
autoComplete="one-time-code"
|
||||||
/>
|
autoFocus
|
||||||
<input
|
className="w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none"
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
placeholder={t("passwordPlaceholder")}
|
|
||||||
autoComplete="current-password"
|
|
||||||
disabled={needs2fa}
|
|
||||||
/>
|
|
||||||
{needs2fa ? (
|
|
||||||
<input
|
|
||||||
value={code}
|
|
||||||
onChange={(e) => 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
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<CaptchaWidget captcha={captcha} nonce={nonce} />
|
|
||||||
)}
|
|
||||||
<button type="submit" className="btn btn-primary" disabled={pending}>
|
|
||||||
{pending ? t("pleaseWait") : needs2fa ? t("verify") : t("signIn")}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{!needs2fa ? (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "0.5rem",
|
|
||||||
margin: "1rem 0",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
height: 1,
|
|
||||||
background: "var(--border-color)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="muted">{t("or")}</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
height: 1,
|
|
||||||
background: "var(--border-color)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "grid", gap: "0.5rem" }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-outline"
|
|
||||||
onClick={() => signIn("discord", { callbackUrl: "/" })}
|
|
||||||
>
|
|
||||||
{t("continueWithDiscord")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-outline"
|
|
||||||
onClick={() => signIn("google", { callbackUrl: "/" })}
|
|
||||||
>
|
|
||||||
{t("continueWithGoogle")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p
|
|
||||||
style={{
|
style={{
|
||||||
color: "var(--color-danger)",
|
backgroundColor: "var(--color-background)",
|
||||||
textAlign: "center",
|
color: "var(--color-text-readable, var(--color-text))",
|
||||||
marginBottom: 0,
|
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
|
) : (
|
||||||
|
<CaptchaWidget captcha={captcha} nonce={nonce} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-center font-semibold" style={{ color: "var(--color-danger)" }}>
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
)}
|
||||||
<p
|
|
||||||
className="muted"
|
<button
|
||||||
style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}
|
type="submit"
|
||||||
|
disabled={pending}
|
||||||
|
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 disabled:opacity-60"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-primary)",
|
||||||
|
color: "var(--color-primary-foreground)",
|
||||||
|
boxShadow: "0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t("noAccount")} <Link href="/register">{t("createOne")}</Link> ·{" "}
|
{pending ? t("pleaseWait") : needs2fa ? t("verify") : t("signIn")}
|
||||||
<Link href="/forgot">{t("forgotPassword")}</Link>
|
</button>
|
||||||
</p>
|
</form>
|
||||||
</ContentCard>
|
|
||||||
</main>
|
{!needs2fa && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-3 my-5">
|
||||||
|
<span className="flex-1 h-px" style={{ background: "color-mix(in srgb, var(--color-text-muted) 15%, transparent)" }} />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
{t("or")}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 h-px" style={{ background: "color-mix(in srgb, var(--color-text-muted) 15%, transparent)" }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full rounded-xl border-2 font-bold text-sm py-3 transition-all hover:scale-[1.02] active:scale-95"
|
||||||
|
onClick={() => signIn("discord", { callbackUrl: "/" })}
|
||||||
|
style={{
|
||||||
|
borderColor: "color-mix(in srgb, var(--color-text-muted) 20%, transparent)",
|
||||||
|
color: "var(--color-text-readable)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("continueWithDiscord")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full rounded-xl border-2 font-bold text-sm py-3 transition-all hover:scale-[1.02] active:scale-95"
|
||||||
|
onClick={() => signIn("google", { callbackUrl: "/" })}
|
||||||
|
style={{
|
||||||
|
borderColor: "color-mix(in srgb, var(--color-text-muted) 20%, transparent)",
|
||||||
|
color: "var(--color-text-readable)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("continueWithGoogle")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p
|
||||||
|
className="text-xs text-center mt-6"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
{t("noAccount")}{" "}
|
||||||
|
<Link href="/register" className="font-extrabold hover:underline" style={{ color: "var(--color-primary)" }}>
|
||||||
|
{t("createOne")}
|
||||||
|
</Link>{" "}
|
||||||
|
·{" "}
|
||||||
|
<Link href="/forgot" className="font-extrabold hover:underline" style={{ color: "var(--color-primary)" }}>
|
||||||
|
{t("forgotPassword")}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+18
-1
@@ -234,7 +234,18 @@
|
|||||||
"noArticles": "No articles yet.",
|
"noArticles": "No articles yet.",
|
||||||
"noPhotos": "No photos yet.",
|
"noPhotos": "No photos yet.",
|
||||||
"noUsers": "No users online",
|
"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": {
|
"news": {
|
||||||
"title": "News",
|
"title": "News",
|
||||||
@@ -894,6 +905,8 @@
|
|||||||
"title": "Sign in",
|
"title": "Sign in",
|
||||||
"subtitle": "Welcome back — log in to enter the hotel.",
|
"subtitle": "Welcome back — log in to enter the hotel.",
|
||||||
"subtitle2fa": "Enter the 6-digit code from your authenticator.",
|
"subtitle2fa": "Enter the 6-digit code from your authenticator.",
|
||||||
|
"welcomeBack": "Welcome back",
|
||||||
|
"welcomeBackSub": "Sign in to continue to {hotelName}",
|
||||||
"usernamePlaceholder": "Username",
|
"usernamePlaceholder": "Username",
|
||||||
"passwordPlaceholder": "Password",
|
"passwordPlaceholder": "Password",
|
||||||
"codePlaceholder": "2FA code",
|
"codePlaceholder": "2FA code",
|
||||||
@@ -927,7 +940,11 @@
|
|||||||
"createAccount": "Create account",
|
"createAccount": "Create account",
|
||||||
"creatingAccount": "Creating account...",
|
"creatingAccount": "Creating account...",
|
||||||
"alreadyHaveAccount": "Already have an account? Login!",
|
"alreadyHaveAccount": "Already have an account? Login!",
|
||||||
|
"alreadyHaveAccountPre": "Already have an account?",
|
||||||
|
"alreadyHaveAccountLink": "Sign in",
|
||||||
"register": "Register",
|
"register": "Register",
|
||||||
|
"whoIsOnline": "Who's online",
|
||||||
|
"usersOnline": "{count} online",
|
||||||
"termsError": "You must accept the terms & rules to register.",
|
"termsError": "You must accept the terms & rules to register.",
|
||||||
"usernameError": "Username is required.",
|
"usernameError": "Username is required.",
|
||||||
"emailError": "Valid email is required.",
|
"emailError": "Valid email is required.",
|
||||||
|
|||||||
Reference in new issue
Block a user