feat: prioritize game iframe, gated register terms and polished register page
CI / check (push) Successful in 28s
CI / release (push) Skipped
CI / deploy (push) Successful in 55s

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";
}
}