feat: prioritize game iframe, gated register terms and polished register page

Fetch the Nitro client iframe with high priority so the browser starts the
game document before competing resources, and replace the bare /client
spinner with a branded boot screen. Preconnect and eager loading were already
in place; typing support for the iframe fetchPriority prop is added in a
React type augmentation.

Rework the register terms block into a single clickable accept control with
a custom check state, error shake and inline hint, and dim the submit button
until the terms are accepted. The register page gets labeled sections
(account details / credentials), a corrected banner overlay, translated
show/hide toggles and a captcha slot that reserves height to avoid layout
shifts. English is the source of truth; other locales fall back to it.
This commit is contained in:
openhands committed 2026-08-29 21:14:02 +02:00
1 parent 7f39ba4257
commit b59d6c21af
6 files changed
+374 -168

No files matched your search

+1
View File
@@ -383,6 +383,7 @@ export function ClientView({
allow="autoplay; gamepad; fullscreen"
referrerPolicy="no-referrer"
loading="eager"
fetchPriority={"high" as const}
/>
</div>
</>
+37 -5
View File
@@ -1,16 +1,48 @@
import Image from "next/image";
export default function ClientLoading() {
return (
<div
className="flex items-center justify-center min-h-screen"
style={{ background: "var(--color-background)" }}
className="relative flex items-center justify-center min-h-screen overflow-hidden"
style={{
background:
"radial-gradient(ellipse at center, color-mix(in srgb, var(--color-surface) 80%, transparent) 0%, var(--color-background) 75%)",
}}
>
<div
className="animate-spin w-10 h-10 border-4 rounded-full"
className="absolute inset-0 animate-pulse-glow"
style={{
borderColor: "var(--color-primary)",
borderTopColor: "transparent",
background:
"radial-gradient(circle at 50% 45%, color-mix(in srgb, var(--color-primary) 12%, transparent) 0%, transparent 55%)",
}}
/>
<div className="relative flex flex-col items-center gap-6">
<div
className="w-20 h-20 rounded-2xl flex items-center justify-center shadow-2xl"
style={{
background:
"linear-gradient(135deg, var(--color-primary) 0%, color-mix(in srgb, var(--color-primary) 70%, black) 100%)",
boxShadow:
"0 12px 40px color-mix(in srgb, var(--color-primary) 35%, transparent)",
}}
>
<Image
src="/assets/images/icons/lighthouse.png"
alt=""
width={44}
height={44}
unoptimized
/>
</div>
<div
className="animate-spin w-9 h-9 border-[3px] rounded-full"
style={{
borderColor:
"color-mix(in srgb, var(--color-text-muted) 18%, transparent)",
borderTopColor: "var(--color-primary)",
}}
/>
</div>
</div>
);
}
+22
View File
@@ -1701,6 +1701,28 @@ details[open] > summary .details-open\:rotate-180 {
.animate-pulse-glow {
animation: pulse-glow 2s ease-in-out infinite;
}
.animate-shake {
animation: shake 0.45s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
}
@keyframes shake {
10%,
90% {
transform: translateX(-2px);
}
20%,
80% {
transform: translateX(4px);
}
30%,
50%,
70% {
transform: translateX(-6px);
}
40%,
60% {
transform: translateX(6px);
}
}
/* Glass morphism */
.glass {
+295 -162
View File
@@ -47,6 +47,8 @@ export function RegisterForm({
FormData
>(register, { error: null, ok: false });
const [termsAccepted, setTermsAccepted] = useState(false);
const [termsError, setTermsError] = useState(false);
const termsRef = useRef<HTMLDivElement>(null);
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
@@ -115,11 +117,16 @@ export function RegisterForm({
className="relative overflow-hidden bg-center bg-cover rounded-t-xl"
style={{
backgroundImage: "url(/assets/images/mybobba_banner.png)",
height: "180px",
maxHeight: "150px",
height: "160px",
}}
>
<div className="absolute inset-0 z-10 bg-black/30" />
<div
className="absolute inset-0 z-10"
style={{
background:
"linear-gradient(180deg, rgba(0,0,0,0.05) 0%, rgba(0,0,0,0.55) 100%)",
}}
/>
<div className="relative flex items-center justify-center w-full h-full z-20">
<div
className="relative animate-float"
@@ -181,208 +188,334 @@ export function RegisterForm({
)}
{/* Username & Email */}
<div className="w-full grid grid-cols-1 md:grid-cols-2 gap-4 stagger-children">
<div className="space-y-1.5">
<label
htmlFor="username"
className="block text-xs font-bold uppercase tracking-wider"
style={{ color: "var(--color-text-muted)" }}
>
{t("username")}
</label>
<input
id="username"
name="username"
type="text"
placeholder={t("usernamePlaceholder")}
autoComplete="username"
className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none"
<div className="space-y-2">
<div className="flex items-center gap-2">
<span
className="text-[11px] font-black uppercase tracking-widest"
style={{
backgroundColor: "var(--color-background)",
color: "var(--color-text-readable)",
borderColor:
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
color: "var(--color-primary-readable, var(--color-primary))",
}}
>
{t("accountDetails")}
</span>
<span
className="h-px flex-1"
style={{
background:
"linear-gradient(90deg, color-mix(in srgb, var(--color-text-muted) 18%, transparent), transparent)",
}}
required
/>
</div>
<div className="space-y-1.5">
<label
htmlFor="mail"
className="block text-xs font-bold uppercase tracking-wider"
style={{ color: "var(--color-text-muted)" }}
>
{t("email")}
</label>
<input
id="mail"
name="mail"
type="email"
placeholder={t("emailPlaceholder")}
autoComplete="email"
className="input-glow 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)",
borderColor:
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 stagger-children">
<div className="space-y-1.5">
<label
htmlFor="username"
className="block text-xs font-bold uppercase tracking-wider"
style={{ color: "var(--color-text-muted)" }}
>
{t("username")}
</label>
<input
id="username"
name="username"
type="text"
placeholder={t("usernamePlaceholder")}
autoComplete="username"
className="input-glow 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)",
borderColor:
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
required
/>
</div>
<div className="space-y-1.5">
<label
htmlFor="mail"
className="block text-xs font-bold uppercase tracking-wider"
style={{ color: "var(--color-text-muted)" }}
>
{t("email")}
</label>
<input
id="mail"
name="mail"
type="email"
placeholder={t("emailPlaceholder")}
autoComplete="email"
className="input-glow 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)",
borderColor:
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
/>
</div>
</div>
</div>
{/* Password & Confirm */}
<div className="w-full grid grid-cols-1 md:grid-cols-2 gap-4 stagger-children">
<div className="space-y-1.5">
<label
htmlFor="password"
className="block text-xs font-bold uppercase tracking-wider"
style={{ color: "var(--color-text-muted)" }}
<div className="space-y-2">
<div className="flex items-center gap-2">
<span
className="text-[11px] font-black uppercase tracking-widest"
style={{
color: "var(--color-primary-readable, var(--color-primary))",
}}
>
{t("password")}
</label>
<div className="relative">
<input
id="password"
name="password"
type={showPassword ? "text" : "password"}
placeholder={t("passwordPlaceholder")}
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input-glow 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)",
borderColor:
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-sm font-bold opacity-60 hover:opacity-100 transition-opacity"
style={{ color: "var(--color-text-muted)" }}
tabIndex={-1}
>
{showPassword ? "Hide" : "Show"}
</button>
</div>
{password.length > 0 && (
<div className="animate-fade-in-up space-y-1">
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className="h-1.5 flex-1 rounded-full transition-all duration-300"
style={{
backgroundColor:
i <= strength.score
? strength.color
: "color-mix(in srgb, var(--color-text-muted) 15%, transparent)",
}}
/>
))}
</div>
<p
className="text-xs font-bold"
style={{ color: strength.color }}
>
{strength.label}
</p>
</div>
)}
{t("credentials")}
</span>
<span
className="h-px flex-1"
style={{
background:
"linear-gradient(90deg, color-mix(in srgb, var(--color-text-muted) 18%, transparent), transparent)",
}}
/>
</div>
<div className="space-y-1.5">
<label
htmlFor="password_confirmation"
className="block text-xs font-bold uppercase tracking-wider"
style={{ color: "var(--color-text-muted)" }}
>
{t("confirmPassword")}
</label>
<div className="relative">
<input
id="password_confirmation"
name="password_confirmation"
type={showConfirm ? "text" : "password"}
placeholder={t("confirmPasswordPlaceholder")}
autoComplete="new-password"
className="input-glow 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)",
borderColor:
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
required
/>
<button
type="button"
onClick={() => setShowConfirm(!showConfirm)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-sm font-bold opacity-60 hover:opacity-100 transition-opacity"
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 stagger-children">
<div className="space-y-1.5">
<label
htmlFor="password"
className="block text-xs font-bold uppercase tracking-wider"
style={{ color: "var(--color-text-muted)" }}
tabIndex={-1}
>
{showConfirm ? "Hide" : "Show"}
</button>
{t("password")}
</label>
<div className="relative">
<input
id="password"
name="password"
type={showPassword ? "text" : "password"}
placeholder={t("passwordPlaceholder")}
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input-glow 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)",
borderColor:
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-sm font-bold opacity-60 hover:opacity-100 transition-opacity"
style={{ color: "var(--color-text-muted)" }}
tabIndex={-1}
>
{showPassword ? t("hidePassword") : t("showPassword")}
</button>
</div>
{password.length > 0 && (
<div className="animate-fade-in-up space-y-1">
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className="h-1.5 flex-1 rounded-full transition-all duration-300"
style={{
backgroundColor:
i <= strength.score
? strength.color
: "color-mix(in srgb, var(--color-text-muted) 15%, transparent)",
}}
/>
))}
</div>
<p
className="text-xs font-bold"
style={{ color: strength.color }}
>
{strength.label}
</p>
</div>
)}
</div>
<div className="space-y-1.5">
<label
htmlFor="password_confirmation"
className="block text-xs font-bold uppercase tracking-wider"
style={{ color: "var(--color-text-muted)" }}
>
{t("confirmPassword")}
</label>
<div className="relative">
<input
id="password_confirmation"
name="password_confirmation"
type={showConfirm ? "text" : "password"}
placeholder={t("confirmPasswordPlaceholder")}
autoComplete="new-password"
className="input-glow 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)",
borderColor:
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
required
/>
<button
type="button"
onClick={() => setShowConfirm(!showConfirm)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-sm font-bold opacity-60 hover:opacity-100 transition-opacity"
style={{ color: "var(--color-text-muted)" }}
tabIndex={-1}
>
{showConfirm ? t("hidePassword") : t("showPassword")}
</button>
</div>
</div>
</div>
</div>
{/* Terms */}
<div
className="rounded-xl p-4 flex gap-4"
ref={termsRef}
className={`rounded-xl p-4 flex items-center justify-between gap-4 transition-all duration-300 ${
termsError ? "animate-shake" : ""
}`}
style={{
backgroundColor:
"color-mix(in srgb, var(--color-background) 50%, var(--color-surface))",
border:
"1px solid color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
border: `1px solid ${
termsError
? "var(--color-danger)"
: "color-mix(in srgb, var(--color-text-muted) 12%, transparent)"
}`,
boxShadow: termsError
? "0 0 0 3px color-mix(in srgb, var(--color-danger) 14%, transparent)"
: undefined,
}}
>
<div className="flex items-center gap-2">
<label
htmlFor="terms"
className="group flex flex-1 items-center gap-3 cursor-pointer select-none"
>
<input
type="checkbox"
id="terms"
name="terms"
checked={termsAccepted}
onChange={(e) => setTermsAccepted(e.target.checked)}
className="w-5 h-5 rounded border-2 accent-(--color-primary) shrink-0 cursor-pointer"
onChange={(e) => {
setTermsAccepted(e.target.checked);
if (e.target.checked) setTermsError(false);
}}
aria-invalid={termsError}
className="peer sr-only"
/>
<label
htmlFor="terms"
className="font-medium text-sm text-[var(--color-text-readable)] cursor-pointer"
<span
aria-hidden="true"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border-2 transition-all duration-300 group-hover:scale-105 group-hover:border-[var(--color-primary)] peer-focus-visible:ring-2 peer-focus-visible:ring-[var(--color-primary)]"
style={{
borderColor: termsError
? "var(--color-danger)"
: termsAccepted
? "var(--color-primary)"
: "color-mix(in srgb, var(--color-text-muted) 45%, transparent)",
background: termsAccepted
? "var(--color-primary)"
: "transparent",
boxShadow: termsAccepted
? "0 0 18px color-mix(in srgb, var(--color-primary) 40%, transparent)"
: undefined,
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="var(--color-primary-foreground)"
strokeWidth="3.5"
strokeLinecap="round"
strokeLinejoin="round"
className={`transition-all duration-300 ${
termsAccepted ? "opacity-100 scale-100" : "opacity-0 scale-50"
}`}
>
<title>{t("accepted")}</title>
<polyline points="20 6 9 17 4 12" />
</svg>
</span>
<span
className="text-sm font-semibold leading-snug"
style={{ color: "var(--color-text-readable)" }}
>
{t("termsAccept", { hotel: hotelName })}
</label>
</div>
<p className="text-xs text-[var(--color-text-muted)]">
* Required for account creation
</p>
</span>
</label>
<span
className="hidden sm:inline-flex shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider transition-colors duration-300"
style={{
color: termsError
? "var(--color-danger)"
: termsAccepted
? "var(--color-primary)"
: "var(--color-text-muted)",
background: termsError
? "color-mix(in srgb, var(--color-danger) 10%, transparent)"
: termsAccepted
? "color-mix(in srgb, var(--color-primary) 12%, transparent)"
: "color-mix(in srgb, var(--color-text-muted) 6%, transparent)",
}}
>
{termsError || !termsAccepted ? t("termsRequired") : t("accepted")}
</span>
</div>
{termsError && (
<p
className="animate-fade-in-up text-xs font-bold"
style={{ color: "var(--color-danger)" }}
>
{t("termsRequired")}
</p>
)}
{/* Captcha */}
{showCaptcha && captcha.provider === "turnstile" && (
<div className="cf-turnstile" data-sitekey={captcha.siteKey} />
)}
{showCaptcha && captcha.provider === "recaptcha" && (
<div className="g-recaptcha" data-sitekey={captcha.siteKey} />
)}
{showCaptcha && captcha.provider === "hcaptcha" && (
<div className="h-captcha" data-sitekey={captcha.siteKey} />
{showCaptcha && (
<div className="flex min-h-[70px] items-center justify-center">
{captcha.provider === "turnstile" && (
<div className="cf-turnstile" data-sitekey={captcha.siteKey} />
)}
{captcha.provider === "recaptcha" && (
<div className="g-recaptcha" data-sitekey={captcha.siteKey} />
)}
{captcha.provider === "hcaptcha" && (
<div className="h-captcha" data-sitekey={captcha.siteKey} />
)}
</div>
)}
{/* Submit */}
<button
type="submit"
disabled={isPending || signingIn}
className="btn-shine w-full rounded-xl font-extrabold text-sm py-3.5 transition-all duration-200 hover:scale-[1.02] active:scale-95 shadow-lg disabled:opacity-60"
onClick={() => {
if (!termsAccepted) {
setTermsError(true);
termsRef.current?.scrollIntoView({
behavior: "smooth",
block: "center",
});
}
}}
className={`btn-shine w-full rounded-xl font-extrabold text-sm py-3.5 transition-all duration-200 hover:scale-[1.02] active:scale-95 shadow-lg disabled:opacity-60 ${
termsAccepted ? "" : "opacity-70 saturate-[0.85] hover:scale-100"
}`}
style={{
background: "var(--color-primary)",
color: "var(--color-primary-foreground)",
boxShadow:
"0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)",
boxShadow: termsAccepted
? "0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)"
: "0 2px 10px color-mix(in srgb, var(--color-primary) 12%, transparent)",
}}
>
{isPending || signingIn ? (
+6 -1
View File
@@ -897,10 +897,15 @@
"confirmPassword": "Repeat password",
"usernamePlaceholder": "Username",
"emailPlaceholder": "Email",
"passwordPlaceholder": "Password (min 6 chars)",
"passwordPlaceholder": "Password (min 8 chars)",
"confirmPasswordPlaceholder": "Repeat password",
"selectOutfit": "Select your outfit",
"termsAccept": "I am 18+ and accept the {hotel} terms & rules.",
"accepted": "Accepted & agreed",
"showPassword": "Show",
"hidePassword": "Hide",
"accountDetails": "Account details",
"credentials": "Credentials & security",
"createAccount": "Create account",
"creatingAccount": "Creating account...",
"redirecting": "Redirecting to your account...",
+13
View File
@@ -0,0 +1,13 @@
import type { HTMLAttributes } from "react";
declare module "react" {
interface IframeHTMLAttributes<T> extends HTMLAttributes<T> {
/**
* Browser support for `fetchpriority` on iframes exists (Chrome 126+,
* Safari 17.5+, Firefox 129+); React forwards the `fetchPriority` prop
* to a `fetchpriority` attribute so the game document is fetched with
* high priority instead of competing with other resources.
*/
fetchPriority?: "high" | "low" | "auto";
}
}