diff --git a/src/components/login/LoginView.tsx b/src/components/login/LoginView.tsx index 37e36df..bd698bd 100644 --- a/src/components/login/LoginView.tsx +++ b/src/components/login/LoginView.tsx @@ -265,6 +265,7 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa const [ loginImagesVersion, setLoginImagesVersion ] = useState(0); const loginUrl = GetConfigurationValue('login.endpoint', '/api/auth/login'); const registerUrl = GetConfigurationValue('login.register.endpoint', '/api/auth/register'); + const roomTemplatesUrl = GetConfigurationValue('login.room_templates.endpoint', '/api/auth/room-templates'); const forgotUrl = GetConfigurationValue('login.forgot.endpoint', '/api/auth/forgot-password'); const newsUrl = interpolate(GetConfigurationValue('login.news.url', '')); const turnstileSiteKey = GetConfigurationValue('login.turnstile.sitekey', ''); @@ -601,7 +602,7 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa } }, [ checkUsernameUrl, postJson ]); - const handleRegisterSubmit = useCallback(async (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; }, onDialogReset: () => void) => + const handleRegisterSubmit = useCallback(async (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; templateId: number | null; }, onDialogReset: () => void) => { if(turnstileEnabled && !body.turnstileToken) { @@ -621,7 +622,8 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa password: body.password, figure: body.figure, gender: body.gender, - turnstileToken: turnstileEnabled ? body.turnstileToken : undefined + turnstileToken: turnstileEnabled ? body.turnstileToken : undefined, + templateId: body.templateId ?? undefined }); if(ok) @@ -848,6 +850,7 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa info={ info } turnstileEnabled={ turnstileEnabled } turnstileSiteKey={ turnstileSiteKey } + roomTemplatesUrl={ roomTemplatesUrl } /> } { mode === 'forgot' && @@ -876,13 +879,22 @@ interface DialogSharedProps interface RegisterDialogProps extends DialogSharedProps { - onSubmit: (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; }, onDialogReset: () => void) => Promise | void; + onSubmit: (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; templateId: number | null; }, onDialogReset: () => void) => Promise | void; onCheckEmail: (email: string) => Promise<{ available: boolean; error?: string }>; onCheckUsername: (username: string) => Promise<{ available: boolean; error?: string }>; onCheckServer: () => Promise; + roomTemplatesUrl: string; } -type RegisterStep = 'credentials' | 'avatar'; +type RegisterStep = 'credentials' | 'avatar' | 'room'; + +interface RoomTemplate +{ + templateId: number; + title: string; + description: string; + thumbnail: string; +} const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -1116,7 +1128,7 @@ const AvatarPartRow: FC = ({ setType, selection, gender, onP const RegisterDialog: FC = props => { - const { onCancel, onSubmit, onCheckEmail, onCheckUsername, onCheckServer, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props; + const { onCancel, onSubmit, onCheckEmail, onCheckUsername, onCheckServer, submitting, error, info, turnstileEnabled, turnstileSiteKey, roomTemplatesUrl } = props; const [ step, setStep ] = useState('credentials'); const [ email, setEmail ] = useState(''); @@ -1129,6 +1141,9 @@ const RegisterDialog: FC = props => const [ prevStep, setPrevStep ] = useState(step); const [ turnstileToken, setTurnstileToken ] = useState(''); const [ resetSignal, setResetSignal ] = useState(0); + const [ roomTemplates, setRoomTemplates ] = useState(null); + const [ roomTemplatesError, setRoomTemplatesError ] = useState(null); + const [ selectedTemplateId, setSelectedTemplateId ] = useState(null); if(prevStep !== step) { @@ -1463,26 +1478,65 @@ const RegisterDialog: FC = props => return null; } + setStep('room'); + return null; + }, [ username, turnstileEnabled, turnstileToken, pingServer, onCheckUsername ]); + + const [ , submitAvatarAction, isAvatarPending ] = useActionState(avatarAction, null); + + const roomAction = useCallback(async (_prev: null, _formData: FormData): Promise => + { + setLocalError(null); + await onSubmit({ - username: trimmed, + username: username.trim(), email: email.trim(), password, figure: buildFigureString(selection), gender, - turnstileToken + turnstileToken, + templateId: selectedTemplateId }, resetWidget); return null; - }, [ username, turnstileEnabled, turnstileToken, pingServer, onCheckUsername, onSubmit, email, password, selection, gender, resetWidget ]); + }, [ onSubmit, username, email, password, selection, gender, turnstileToken, selectedTemplateId, resetWidget ]); - const [ , submitAvatarAction, isAvatarPending ] = useActionState(avatarAction, null); + const [ , submitRoomAction, isRoomPending ] = useActionState(roomAction, null); - const busy = submitting || isCredentialsPending || isAvatarPending || pingingServer; + useEffect(() => + { + if(step !== 'room' || roomTemplates !== null || !roomTemplatesUrl) return; + let cancelled = false; + setRoomTemplatesError(null); + fetch(roomTemplatesUrl, { credentials: 'include' }) + .then(async r => + { + if(!r.ok) throw new Error(`status ${ r.status }`); + return r.json(); + }) + .then(json => + { + if(cancelled) return; + const list = Array.isArray((json as { templates?: unknown })?.templates) + ? (json as { templates: RoomTemplate[] }).templates + : []; + setRoomTemplates(list); + }) + .catch(() => + { + if(cancelled) return; + setRoomTemplates([]); + setRoomTemplatesError(t('nitro.login.register.room.error', 'Could not load room options. You can still skip this step.')); + }); + return () => { cancelled = true; }; + }, [ step, roomTemplates, roomTemplatesUrl ]); + + const busy = submitting || isCredentialsPending || isAvatarPending || isRoomPending || pingingServer; const serverOffline = serverReachable === false; return (
-
+
{ t('nitro.login.register.title', 'Habbo Details') } @@ -1520,7 +1574,7 @@ const RegisterDialog: FC = props => { (localError || error) &&
{ localError || error }
} { info &&
{ info }
}
- 1/2 + 1/3 @@ -1622,9 +1676,67 @@ const RegisterDialog: FC = props =>
- 2/2 + 2/3 +
+ + } + + { step === 'room' && +
+
+ { t('nitro.login.register.intro.room', 'Last step — pick a starter room, or skip and create your own later.') } +
+ + { serverOffline && +
+ { t('nitro.login.server.offline.long', 'The gameserver isn\'t running right now, so new accounts can\'t be created. Please try again in a moment.') } + +
+ } + +
+ + + { roomTemplates === null &&
{ t('nitro.login.register.room.loading', 'Loading rooms…') }
} + + { roomTemplates !== null && roomTemplates.map(template => ( + + )) } +
+ + { roomTemplatesError &&
{ roomTemplatesError }
} + { (localError || error) &&
{ localError || error }
} + { info &&
{ info }
} + +
+ + 3/3 +
diff --git a/src/css/login/LoginView.css b/src/css/login/LoginView.css index d08e028..fae3328 100644 --- a/src/css/login/LoginView.css +++ b/src/css/login/LoginView.css @@ -1174,3 +1174,99 @@ animation-duration: 0.4s !important; } } + +/* ─── Register dialog · room template step ─── */ + +.nitro-login-modal .dialog.dialog-room { + width: 400px; +} + +.nitro-login-card .room-templates-list { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 280px; + overflow-y: auto; + overflow-x: hidden; + padding-right: 4px; + margin-top: 2px; + scrollbar-width: thin; +} + +.nitro-login-card .room-template-option { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + background: linear-gradient(180deg, #f4f9fc 0%, #dbe9f1 100%); + border: 1px solid #a4c0d2; + border-radius: 6px; + cursor: pointer; + transition: border-color 0.12s ease, box-shadow 0.12s ease, background 0.12s ease; + color: #0a2e45; + user-select: none; + position: relative; +} + +.nitro-login-card .room-template-option:hover { + border-color: #4a8ec0; + box-shadow: 0 0 0 1px rgba(74, 142, 192, 0.45); +} + +.nitro-login-card .room-template-option.selected { + border-color: #2a78b8; + background: linear-gradient(180deg, #eaf4fb 0%, #c5dcec 100%); + box-shadow: 0 0 0 2px rgba(42, 120, 184, 0.55), 0 1px 2px rgba(0, 0, 0, 0.08); +} + +.nitro-login-card .room-template-option input[type="radio"] { + flex-shrink: 0; + margin: 0; + accent-color: #2a78b8; + cursor: pointer; +} + +.nitro-login-card .room-template-thumb { + width: 64px; + height: 48px; + object-fit: cover; + border-radius: 4px; + border: 1px solid #a4c0d2; + background: #fff; + flex-shrink: 0; + image-rendering: pixelated; +} + +.nitro-login-card .room-template-body { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} + +.nitro-login-card .room-template-title { + font-size: 12px; + font-weight: 700; + color: #0a2e45; + line-height: 1.2; + text-transform: capitalize; +} + +.nitro-login-card .room-template-description { + font-size: 11px; + line-height: 1.35; + color: #486175; + word-break: break-word; +} + +/* The "Skip" option sits at the top with a softer dashed border so it + visually distinguishes itself from the actual templates. */ +.nitro-login-card .room-template-option.room-template-skip { + background: linear-gradient(180deg, #fbfcfd 0%, #e7eef4 100%); + border-style: dashed; +} + +.nitro-login-card .room-template-option.room-template-skip.selected { + border-style: solid; +}