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 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 (
|
||||
<LoginForm
|
||||
captcha={{
|
||||
provider: cfg.provider,
|
||||
siteKey: cfg.siteKey || undefined,
|
||||
field: cfg.field || undefined,
|
||||
}}
|
||||
nonce={nonce}
|
||||
/>
|
||||
<div className="flex flex-col lg:flex-row gap-10">
|
||||
{/* Left panel */}
|
||||
<div className="lg:w-80 shrink-0 space-y-4">
|
||||
<div
|
||||
className="rounded-2xl border p-6 text-center"
|
||||
style={{
|
||||
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";
|
||||
|
||||
/**
|
||||
* 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>;
|
||||
}
|
||||
+89
-38
@@ -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 (
|
||||
<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 ── */}
|
||||
<div className="flex flex-col lg:flex-row items-center gap-10 lg:gap-16">
|
||||
<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" />
|
||||
{online.toLocaleString()} online
|
||||
{th("online", { count: online, hotel: hotelName ?? "Hotel" })}
|
||||
</div>
|
||||
<h1
|
||||
className="text-4xl md:text-5xl lg:text-6xl font-black leading-[1.05] tracking-tight mb-4"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
>
|
||||
Welcome to{" "}
|
||||
<span
|
||||
style={{
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
}}
|
||||
>
|
||||
{hotelName}
|
||||
</span>
|
||||
{tp("welcome", { hotelName: hotelName ?? "Hotel" })}
|
||||
</h1>
|
||||
<p
|
||||
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))",
|
||||
}}
|
||||
>
|
||||
{t("tagline")}
|
||||
{th("tagline")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3 justify-center lg:justify-start">
|
||||
<Link
|
||||
@@ -124,7 +133,7 @@ export default async function Home() {
|
||||
height={20}
|
||||
unoptimized
|
||||
/>
|
||||
Enter {hotelName}
|
||||
{tp("enterHotel", { hotelName: hotelName ?? "Hotel" })}
|
||||
</Link>
|
||||
<Link
|
||||
href="/client"
|
||||
@@ -135,7 +144,7 @@ export default async function Home() {
|
||||
color: "var(--color-text-readable)",
|
||||
}}
|
||||
>
|
||||
Play now
|
||||
{th("nitroClient")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,7 +190,7 @@ export default async function Home() {
|
||||
{u.username}
|
||||
</p>
|
||||
<p className="text-[10px] font-semibold text-green-500">
|
||||
Online
|
||||
{tp("statsOnline")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -193,9 +202,9 @@ export default async function Home() {
|
||||
{/* ── 2. Stats ── */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[
|
||||
{ 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) => (
|
||||
<div
|
||||
key={s.label}
|
||||
@@ -222,28 +231,70 @@ export default async function Home() {
|
||||
))}
|
||||
</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">
|
||||
{[
|
||||
{
|
||||
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) => (
|
||||
<div
|
||||
@@ -280,7 +331,7 @@ export default async function Home() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── 4. News + Sidebar ── */}
|
||||
{/* ── 5. News + Sidebar ── */}
|
||||
<div className="grid lg:grid-cols-12 gap-6">
|
||||
<div className="lg:col-span-8 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -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")}
|
||||
</h2>
|
||||
<Link
|
||||
href="/news"
|
||||
className="text-sm font-bold hover:underline"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
>
|
||||
View all →
|
||||
{tp("allNews")}
|
||||
</Link>
|
||||
</div>
|
||||
{articles.length > 0 ? (
|
||||
@@ -349,7 +400,7 @@ export default async function Home() {
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
No news yet.
|
||||
{tp("noArticles")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -369,7 +420,7 @@ export default async function Home() {
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full bg-green-500" />
|
||||
Online now
|
||||
{tp("recentUsers")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{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")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{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" })}
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* ── 5. CTA ── */}
|
||||
{/* ── 6. CTA ── */}
|
||||
<div
|
||||
className="rounded-2xl border text-center px-6 py-10"
|
||||
style={{
|
||||
@@ -480,13 +531,13 @@ export default async function Home() {
|
||||
className="text-2xl md:text-3xl font-black tracking-tight mb-2"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
>
|
||||
Ready to join?
|
||||
{tp("joinNow", { hotelName: hotelName ?? "Hotel" })}
|
||||
</h2>
|
||||
<p
|
||||
className="text-sm mb-6 max-w-sm mx-auto"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Create your free account and start your adventure today.
|
||||
{tp("welcomeBody")}
|
||||
</p>
|
||||
<Link
|
||||
href="/register"
|
||||
@@ -505,7 +556,7 @@ export default async function Home() {
|
||||
height={20}
|
||||
unoptimized
|
||||
/>
|
||||
Create free account
|
||||
{tp("register")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
}}
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full bg-green-500" />
|
||||
{online.toLocaleString()} online
|
||||
{tpr("usersOnline", { count: online })}
|
||||
</div>
|
||||
<h1
|
||||
className="text-xl font-black"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
>
|
||||
Join{" "}
|
||||
<span
|
||||
style={{
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
}}
|
||||
>
|
||||
{hotelName}
|
||||
</span>
|
||||
{tpr("title")} — {hotelName}
|
||||
</h1>
|
||||
<p
|
||||
className="text-xs mt-1 mx-auto max-w-[200px] leading-relaxed"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{t("tagline")}
|
||||
{th("tagline")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{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")}{" "}
|
||||
<Link
|
||||
href="/login"
|
||||
className="font-extrabold hover:underline"
|
||||
@@ -151,7 +145,7 @@ export default async function RegisterPage() {
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
}}
|
||||
>
|
||||
Sign in
|
||||
{tpr("alreadyHaveAccountLink")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
@@ -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")}
|
||||
</h2>
|
||||
<p
|
||||
className="text-sm mt-0.5"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Free and takes less than a minute
|
||||
{tpr("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<RegisterForm
|
||||
|
||||
+122
-101
@@ -10,7 +10,6 @@ import {
|
||||
type CaptchaPublicConfig,
|
||||
readCaptchaToken,
|
||||
} from "@/components/auth/captcha-widget";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
|
||||
export function LoginForm({
|
||||
captcha = { provider: "none" },
|
||||
@@ -64,117 +63,139 @@ export function LoginForm({
|
||||
);
|
||||
return;
|
||||
}
|
||||
window.location.href = "/";
|
||||
window.location.href = "/me";
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 420, margin: "2rem auto" }}>
|
||||
<ContentCard
|
||||
icon={needs2fa ? "🔒" : "🔑"}
|
||||
title={t("title")}
|
||||
subtitle={needs2fa ? t("subtitle2fa") : t("subtitle")}
|
||||
>
|
||||
<form onSubmit={onSubmit} style={{ display: "grid", gap: "0.7rem" }}>
|
||||
<>
|
||||
{needs2fa && (
|
||||
<p className="text-sm font-semibold mb-4" style={{ color: "var(--color-text-muted)" }}>
|
||||
{t("subtitle2fa")}
|
||||
</p>
|
||||
)}
|
||||
<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
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder={t("usernamePlaceholder")}
|
||||
autoComplete="username"
|
||||
disabled={needs2fa}
|
||||
/>
|
||||
<input
|
||||
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
|
||||
value={code}
|
||||
onChange={(e) => 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)",
|
||||
}}
|
||||
>
|
||||
/>
|
||||
) : (
|
||||
<CaptchaWidget captcha={captcha} nonce={nonce} />
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-center font-semibold" style={{ color: "var(--color-danger)" }}>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<p
|
||||
className="muted"
|
||||
style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}
|
||||
)}
|
||||
|
||||
<button
|
||||
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> ·{" "}
|
||||
<Link href="/forgot">{t("forgotPassword")}</Link>
|
||||
</p>
|
||||
</ContentCard>
|
||||
</main>
|
||||
{pending ? t("pleaseWait") : needs2fa ? t("verify") : t("signIn")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{!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.",
|
||||
"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.",
|
||||
|
||||
Reference in new issue
Block a user