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" allow="autoplay; gamepad; fullscreen"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
loading="eager" loading="eager"
fetchPriority={"high" as const}
/> />
</div> </div>
</> </>
+37 -5
View File
@@ -1,16 +1,48 @@
import Image from "next/image";
export default function ClientLoading() { export default function ClientLoading() {
return ( return (
<div <div
className="flex items-center justify-center min-h-screen" className="relative flex items-center justify-center min-h-screen overflow-hidden"
style={{ background: "var(--color-background)" }} style={{
background:
"radial-gradient(ellipse at center, color-mix(in srgb, var(--color-surface) 80%, transparent) 0%, var(--color-background) 75%)",
}}
> >
<div <div
className="animate-spin w-10 h-10 border-4 rounded-full" className="absolute inset-0 animate-pulse-glow"
style={{ style={{
borderColor: "var(--color-primary)", background:
borderTopColor: "transparent", "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> </div>
); );
} }
+22
View File
@@ -1701,6 +1701,28 @@ details[open] > summary .details-open\:rotate-180 {
.animate-pulse-glow { .animate-pulse-glow {
animation: pulse-glow 2s ease-in-out infinite; 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 morphism */
.glass { .glass {
+295 -162
View File
@@ -47,6 +47,8 @@ export function RegisterForm({
FormData FormData
>(register, { error: null, ok: false }); >(register, { error: null, ok: false });
const [termsAccepted, setTermsAccepted] = useState(false); const [termsAccepted, setTermsAccepted] = useState(false);
const [termsError, setTermsError] = useState(false);
const termsRef = useRef<HTMLDivElement>(null);
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [showConfirm, setShowConfirm] = 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" className="relative overflow-hidden bg-center bg-cover rounded-t-xl"
style={{ style={{
backgroundImage: "url(/assets/images/mybobba_banner.png)", backgroundImage: "url(/assets/images/mybobba_banner.png)",
height: "180px", height: "160px",
maxHeight: "150px",
}} }}
> >
<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 flex items-center justify-center w-full h-full z-20">
<div <div
className="relative animate-float" className="relative animate-float"
@@ -181,208 +188,334 @@ export function RegisterForm({
)} )}
{/* Username & Email */} {/* Username & Email */}
<div className="w-full grid grid-cols-1 md:grid-cols-2 gap-4 stagger-children"> <div className="space-y-2">
<div className="space-y-1.5"> <div className="flex items-center gap-2">
<label <span
htmlFor="username" className="text-[11px] font-black uppercase tracking-widest"
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={{ style={{
backgroundColor: "var(--color-background)", color: "var(--color-primary-readable, var(--color-primary))",
color: "var(--color-text-readable)", }}
borderColor: >
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)", {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>
<div className="space-y-1.5"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 stagger-children">
<label <div className="space-y-1.5">
htmlFor="mail" <label
className="block text-xs font-bold uppercase tracking-wider" htmlFor="username"
style={{ color: "var(--color-text-muted)" }} className="block text-xs font-bold uppercase tracking-wider"
> style={{ color: "var(--color-text-muted)" }}
{t("email")} >
</label> {t("username")}
<input </label>
id="mail" <input
name="mail" id="username"
type="email" name="username"
placeholder={t("emailPlaceholder")} type="text"
autoComplete="email" placeholder={t("usernamePlaceholder")}
className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none" autoComplete="username"
style={{ className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none"
backgroundColor: "var(--color-background)", style={{
color: "var(--color-text-readable)", backgroundColor: "var(--color-background)",
borderColor: color: "var(--color-text-readable)",
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)", 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>
</div> </div>
{/* Password & Confirm */} {/* Password & Confirm */}
<div className="w-full grid grid-cols-1 md:grid-cols-2 gap-4 stagger-children"> <div className="space-y-2">
<div className="space-y-1.5"> <div className="flex items-center gap-2">
<label <span
htmlFor="password" className="text-[11px] font-black uppercase tracking-widest"
className="block text-xs font-bold uppercase tracking-wider" style={{
style={{ color: "var(--color-text-muted)" }} color: "var(--color-primary-readable, var(--color-primary))",
}}
> >
{t("password")} {t("credentials")}
</label> </span>
<div className="relative"> <span
<input className="h-px flex-1"
id="password" style={{
name="password" background:
type={showPassword ? "text" : "password"} "linear-gradient(90deg, color-mix(in srgb, var(--color-text-muted) 18%, transparent), transparent)",
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>
)}
</div> </div>
<div className="space-y-1.5"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 stagger-children">
<label <div className="space-y-1.5">
htmlFor="password_confirmation" <label
className="block text-xs font-bold uppercase tracking-wider" htmlFor="password"
style={{ color: "var(--color-text-muted)" }} className="block text-xs font-bold uppercase tracking-wider"
>
{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)" }} style={{ color: "var(--color-text-muted)" }}
tabIndex={-1}
> >
{showConfirm ? "Hide" : "Show"} {t("password")}
</button> </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> </div>
</div> </div>
{/* Terms */} {/* Terms */}
<div <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={{ style={{
backgroundColor: backgroundColor:
"color-mix(in srgb, var(--color-background) 50%, var(--color-surface))", "color-mix(in srgb, var(--color-background) 50%, var(--color-surface))",
border: border: `1px solid ${
"1px solid color-mix(in srgb, var(--color-text-muted) 8%, transparent)", 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 <input
type="checkbox" type="checkbox"
id="terms" id="terms"
name="terms" name="terms"
checked={termsAccepted} checked={termsAccepted}
onChange={(e) => setTermsAccepted(e.target.checked)} onChange={(e) => {
className="w-5 h-5 rounded border-2 accent-(--color-primary) shrink-0 cursor-pointer" setTermsAccepted(e.target.checked);
if (e.target.checked) setTermsError(false);
}}
aria-invalid={termsError}
className="peer sr-only"
/> />
<label <span
htmlFor="terms" aria-hidden="true"
className="font-medium text-sm text-[var(--color-text-readable)] cursor-pointer" 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 })} {t("termsAccept", { hotel: hotelName })}
</label> </span>
</div> </label>
<p className="text-xs text-[var(--color-text-muted)]"> <span
* Required for account creation 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"
</p> 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> </div>
{termsError && (
<p
className="animate-fade-in-up text-xs font-bold"
style={{ color: "var(--color-danger)" }}
>
{t("termsRequired")}
</p>
)}
{/* Captcha */} {/* Captcha */}
{showCaptcha && captcha.provider === "turnstile" && ( {showCaptcha && (
<div className="cf-turnstile" data-sitekey={captcha.siteKey} /> <div className="flex min-h-[70px] items-center justify-center">
)} {captcha.provider === "turnstile" && (
{showCaptcha && captcha.provider === "recaptcha" && ( <div className="cf-turnstile" data-sitekey={captcha.siteKey} />
<div className="g-recaptcha" data-sitekey={captcha.siteKey} /> )}
)} {captcha.provider === "recaptcha" && (
{showCaptcha && captcha.provider === "hcaptcha" && ( <div className="g-recaptcha" data-sitekey={captcha.siteKey} />
<div className="h-captcha" data-sitekey={captcha.siteKey} /> )}
{captcha.provider === "hcaptcha" && (
<div className="h-captcha" data-sitekey={captcha.siteKey} />
)}
</div>
)} )}
{/* Submit */} {/* Submit */}
<button <button
type="submit" type="submit"
disabled={isPending || signingIn} 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={{ style={{
background: "var(--color-primary)", background: "var(--color-primary)",
color: "var(--color-primary-foreground)", color: "var(--color-primary-foreground)",
boxShadow: boxShadow: termsAccepted
"0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)", ? "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 ? ( {isPending || signingIn ? (
+6 -1
View File
@@ -897,10 +897,15 @@
"confirmPassword": "Repeat password", "confirmPassword": "Repeat password",
"usernamePlaceholder": "Username", "usernamePlaceholder": "Username",
"emailPlaceholder": "Email", "emailPlaceholder": "Email",
"passwordPlaceholder": "Password (min 6 chars)", "passwordPlaceholder": "Password (min 8 chars)",
"confirmPasswordPlaceholder": "Repeat password", "confirmPasswordPlaceholder": "Repeat password",
"selectOutfit": "Select your outfit", "selectOutfit": "Select your outfit",
"termsAccept": "I am 18+ and accept the {hotel} terms & rules.", "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", "createAccount": "Create account",
"creatingAccount": "Creating account...", "creatingAccount": "Creating account...",
"redirecting": "Redirecting to your 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";
}
}