import { GetConfiguration } from '@nitrots/nitro-renderer'; import { FC, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ClearRememberLogin, GetConfigurationValue, GetRememberLogin, StoreRememberLoginFromPayload } from '../../api'; import { configFileUrl } from '../../secure-assets'; import flagBr from '../../assets/images/flag_icon/flag_icon_br.png'; import flagDe from '../../assets/images/flag_icon/flag_icon_de.png'; import flagEn from '../../assets/images/flag_icon/flag_icon_en.png'; import flagEs from '../../assets/images/flag_icon/flag_icon_es.png'; import flagFi from '../../assets/images/flag_icon/flag_icon_fi.png'; import flagFr from '../../assets/images/flag_icon/flag_icon_fr.png'; import flagIt from '../../assets/images/flag_icon/flag_icon_it.png'; import flagNl from '../../assets/images/flag_icon/flag_icon_nl.png'; import flagSelected from '../../assets/images/flag_icon/flag_icon_selected.png'; import flagTr from '../../assets/images/flag_icon/flag_icon_tr.png'; import { applyTextTranslationLocale } from '../../hooks/translation/useTranslation'; import { TurnstileWidget } from './TurnstileWidget'; type DialogMode = 'login' | 'register' | 'forgot'; type LoginLocale = { code: string; file: string; label: string; flag: string }; const interpolate = (value: string | null | undefined): string => { if(!value) return ''; let output = value; try { output = GetConfiguration().interpolate(value) || value; } catch {} return output.replace(/\$\{([^}]+)\}/g, (_, key: string) => { if(key === 'api.url' && typeof (window as any).NitroSecureApiUrl === 'string') { const secureApiUrl = (window as any).NitroSecureApiUrl.replace(/\/$/, ''); if(secureApiUrl) return secureApiUrl; } try { const configValue = GetConfiguration().getValue(key, ''); if(configValue) return configValue; } catch {} try { const configValue = GetConfigurationValue(key, ''); if(configValue) return configValue; } catch {} return ''; }); }; const LOCK_KEY = 'nitro.login.lock'; const CHAT_TRANSLATION_SETTINGS_KEY = 'chatTranslationSettings'; const MAX_ATTEMPTS = 5; const LOCK_WINDOW_MS = 60_000; const LOCK_DURATION_MS = 2 * 60_000; const getDefaultLoginImages = (): Record => { const imagesBase = (GetConfigurationValue('images.url', '') || '').replace(/\/$/, ''); if(!imagesBase.length) return { 'background.colour': '#6eadc8' }; return { background: `${ imagesBase }/reception/background_gradient_apr25.png`, 'background.colour': '#6eadc8', drape: `${ imagesBase }/reception/drape.png`, left: `${ imagesBase }/reception/mute_reception_backdrop_left.png`, right: `${ imagesBase }/reception/background_right.png` }; }; const LOGIN_LOCALES: LoginLocale[] = [ { code: 'it', file: 'it', label: 'Italiano', flag: flagIt }, { code: 'en', file: 'com', label: 'English', flag: flagEn }, { code: 'es', file: 'es', label: 'Español', flag: flagEs }, { code: 'fr', file: 'fr', label: 'Français', flag: flagFr }, { code: 'de', file: 'de', label: 'Deutsch', flag: flagDe }, { code: 'pt-BR', file: 'br', label: 'Português', flag: flagBr }, { code: 'nl', file: 'nl', label: 'Nederlands', flag: flagNl }, { code: 'fi', file: 'fi', label: 'Suomi', flag: flagFi }, { code: 'tr', file: 'tr', label: 'Türkçe', flag: flagTr } ]; type AttemptState = { attempts: number; firstAt: number; lockedUntil: number }; const readLock = (): AttemptState => { try { const raw = sessionStorage.getItem(LOCK_KEY); if(!raw) return { attempts: 0, firstAt: 0, lockedUntil: 0 }; return JSON.parse(raw); } catch { return { attempts: 0, firstAt: 0, lockedUntil: 0 }; } }; const writeLock = (state: AttemptState) => { try { sessionStorage.setItem(LOCK_KEY, JSON.stringify(state)); } catch { } }; const normalizeLanguageCode = (value: string): string => { if(!value) return ''; const normalized = value.trim().replace('_', '-'); const parts = normalized.split('-'); if(parts.length === 1) return parts[0].toLowerCase(); return `${ parts[0].toLowerCase() }-${ parts[1].toUpperCase() }`; }; const resolveLoginLocale = (value: string): LoginLocale => { const normalized = normalizeLanguageCode(value); const exactMatch = LOGIN_LOCALES.find(locale => normalizeLanguageCode(locale.code) === normalized); if(exactMatch) return exactMatch; const base = normalized.split('-')[0]; if(base === 'pt') return LOGIN_LOCALES.find(locale => locale.file === 'br') || LOGIN_LOCALES[0]; return LOGIN_LOCALES.find(locale => normalizeLanguageCode(locale.code).split('-')[0] === base) || LOGIN_LOCALES[0]; }; const getBrowserLocale = (): LoginLocale => { if(typeof navigator === 'undefined') return LOGIN_LOCALES[0]; return resolveLoginLocale(navigator.language || navigator.languages?.[0] || 'it'); }; const readCachedLocale = (): LoginLocale => { try { const settings = JSON.parse(localStorage.getItem(CHAT_TRANSLATION_SETTINGS_KEY) || '{}'); if(typeof settings.uiTextLanguage === 'string' && settings.uiTextLanguage.length) return resolveLoginLocale(settings.uiTextLanguage); } catch {} return getBrowserLocale(); }; const applyLocaleSelection = (locale: LoginLocale): void => { try { const previousSettings = JSON.parse(localStorage.getItem(CHAT_TRANSLATION_SETTINGS_KEY) || '{}'); const nextSettings = { enabled: previousSettings.enabled ?? false, incomingTargetLanguage: previousSettings.incomingTargetLanguage || locale.code, outgoingTargetLanguage: previousSettings.outgoingTargetLanguage || locale.code, ...previousSettings, uiTextLanguage: locale.code }; localStorage.setItem(CHAT_TRANSLATION_SETTINGS_KEY, JSON.stringify(nextSettings)); } catch {} }; export interface LoginViewProps { onAuthenticated: (ssoTicket: string) => void; isEntering?: boolean; } export const LoginView: FC = ({ onAuthenticated, isEntering = false }) => { const [ mode, setMode ] = useState('login'); const [ username, setUsername ] = useState(''); const [ password, setPassword ] = useState(''); const [ error, setError ] = useState(null); const [ info, setInfo ] = useState(null); const [ submitting, setSubmitting ] = useState(false); const [ loginTurnstileToken, setLoginTurnstileToken ] = useState(''); const [ loginTurnstileResetSignal, setLoginTurnstileResetSignal ] = useState(0); const [ loginServerReachable, setLoginServerReachable ] = useState(null); const [ loginPingingServer, setLoginPingingServer ] = useState(false); const [ rememberMe, setRememberMe ] = useState(() => !!GetRememberLogin()); const [ selectedLocale, setSelectedLocale ] = useState(() => readCachedLocale()); const [ localeApplying, setLocaleApplying ] = useState(false); const [ localeError, setLocaleError ] = useState(''); const [ loginViewConfig, setLoginViewConfig ] = useState>(() => GetConfigurationValue>('loginview', {})); const submitTimeRef = useRef(0); const configuredLoginImages: Record = (loginViewConfig?.['images'] as Record) ?? {}; const loginImages: Record = { ...getDefaultLoginImages(), ...configuredLoginImages }; const configuredLoginWidgets: Record = (loginViewConfig?.['widgets'] as Record) ?? {}; const loginWidgetSlots = useMemo(() => { return Object.entries(configuredLoginWidgets) .filter(([ key, value ]) => key.startsWith('slot.') && key.endsWith('.widget') && typeof value === 'string' && value.length > 0) .map(([ key, value ]) => { const slotNum = key.match(/\d+/)?.[0] ?? ''; const conf = configuredLoginWidgets[`slot.${ slotNum }.conf`] as Record ?? {}; return { key, slotNum: Number(slotNum), type: value as string, conf }; }) .filter(slot => slot.slotNum > 0) .sort((a, b) => a.slotNum - b.slotNum); }, [ configuredLoginWidgets ]); const backgroundColor = (loginImages['background.colour'] || GetConfigurationValue('login_background.colour', '#6eadc8')); const background = interpolate(loginImages['background'] || GetConfigurationValue('login_background', '')); const sun = interpolate(loginImages['sun'] || GetConfigurationValue('login_sun', '')); const drape = interpolate(loginImages['drape'] || GetConfigurationValue('login_drape', '')); const left = interpolate(loginImages['left'] || GetConfigurationValue('login_left', '')); const rightRepeat = interpolate(loginImages['right.repeat'] || GetConfigurationValue('login_right.repeat', '')); const right = interpolate(loginImages['right'] || GetConfigurationValue('login_right', '')); const widgetImageUrls = useMemo(() => loginWidgetSlots .map(slot => typeof slot.conf.image === 'string' ? interpolate(slot.conf.image) : '') .filter(Boolean), [ loginWidgetSlots ]); const loginImageUrls = useMemo(() => [ background, sun, drape, left, rightRepeat, right, ...widgetImageUrls ].filter(Boolean), [ background, sun, drape, left, rightRepeat, right, widgetImageUrls ]); const [ loginImagesVersion, setLoginImagesVersion ] = useState(0); const loginUrl = GetConfigurationValue('login.endpoint', '/api/auth/login'); const registerUrl = GetConfigurationValue('login.register.endpoint', '/api/auth/register'); const forgotUrl = GetConfigurationValue('login.forgot.endpoint', '/api/auth/forgot-password'); const turnstileSiteKey = GetConfigurationValue('login.turnstile.sitekey', ''); const rawTurnstileEnabled = GetConfigurationValue('login.turnstile.enabled', false); const turnstileEnabled = (rawTurnstileEnabled === true || rawTurnstileEnabled === 'true' || rawTurnstileEnabled === 1 || rawTurnstileEnabled === '1') && !!turnstileSiteKey; const resetLoginTurnstile = useCallback(() => { setLoginTurnstileToken(''); setLoginTurnstileResetSignal(prev => prev + 1); }, []); useEffect(() => { setError(null); if(mode === 'login') resetLoginTurnstile(); }, [ mode, resetLoginTurnstile ]); useEffect(() => { let cancelled = false; const refreshLoginViewConfig = () => { if(cancelled) return; const nextConfig = GetConfigurationValue>('loginview', {}); setLoginViewConfig(previousConfig => { try { return JSON.stringify(previousConfig) === JSON.stringify(nextConfig) ? previousConfig : nextConfig; } catch { return nextConfig; } }); }; refreshLoginViewConfig(); const timers = [ 50, 150, 300, 600, 1000, 2000 ].map(delay => window.setTimeout(refreshLoginViewConfig, delay)); return () => { cancelled = true; timers.forEach(timer => window.clearTimeout(timer)); }; }, []); const confirmLocaleSelection = useCallback(async () => { if(localeApplying) return; setLocaleApplying(true); setLocaleError(''); try { applyLocaleSelection(selectedLocale); await applyTextTranslationLocale(selectedLocale.code); } catch { setLocaleError('Unable to load this language pack.'); } finally { setLocaleApplying(false); } }, [ localeApplying, selectedLocale ]); useEffect(() => { if(!loginImageUrls.length) return; let cancelled = false; loginImageUrls.forEach(url => { const image = new Image(); image.onload = image.onerror = () => { if(!cancelled) setLoginImagesVersion(version => version + 1); }; image.src = url; }); return () => { cancelled = true; }; }, [ loginImageUrls ]); useEffect(() => { if(!info) return; const timeout = window.setTimeout(() => setInfo(null), 8000); return () => window.clearTimeout(timeout); }, [ info ]); const lockState = useMemo(() => readLock(), [ submitting ]); const now = Date.now(); const isLocked = lockState.lockedUntil > now; const recordFailure = useCallback(() => { const state = readLock(); const currentNow = Date.now(); if(currentNow - state.firstAt > LOCK_WINDOW_MS) { writeLock({ attempts: 1, firstAt: currentNow, lockedUntil: 0 }); return; } const attempts = state.attempts + 1; const lockedUntil = attempts >= MAX_ATTEMPTS ? currentNow + LOCK_DURATION_MS : 0; writeLock({ attempts, firstAt: state.firstAt || currentNow, lockedUntil }); }, []); const clearLock = useCallback(() => { writeLock({ attempts: 0, firstAt: 0, lockedUntil: 0 }); }, []); const postJson = useCallback(async (url: string, body: Record) => { const response = await fetch(url, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-Requested-With': 'NitroLoginView' }, body: JSON.stringify(body) }); let payload: Record = {}; try { payload = await response.json(); } catch { } return { ok: response.ok, status: response.status, payload }; }, []); const healthUrl = GetConfigurationValue('login.health.endpoint', ''); const healthMethodRaw = GetConfigurationValue('login.health.method', 'GET'); const healthMethod = (healthMethodRaw || 'GET').toUpperCase(); const checkServerReachable = useCallback(async (): Promise => { if(!healthUrl) return true; try { const controller = new AbortController(); const timer = window.setTimeout(() => controller.abort(), 5000); try { const response = await fetch(healthUrl, { method: healthMethod, credentials: 'omit', signal: controller.signal }); if(response.status === 403) return false; if(response.status >= 500) return false; return true; } finally { window.clearTimeout(timer); } } catch { return false; } }, [ healthUrl, healthMethod ]); const pingLoginServer = useCallback(async () => { setLoginPingingServer(true); try { const ok = await checkServerReachable(); setLoginServerReachable(ok); return ok; } finally { setLoginPingingServer(false); } }, [ checkServerReachable ]); const handleLoginSubmit = useCallback(async (event: FormEvent) => { event.preventDefault(); if(submitting || isEntering) return; const nowTs = Date.now(); if(nowTs - submitTimeRef.current < 1000) return; submitTimeRef.current = nowTs; const state = readLock(); if(state.lockedUntil > nowTs) { const remaining = Math.ceil((state.lockedUntil - nowTs) / 1000); setError(`Too many attempts. Try again in ${ remaining }s.`); return; } if(!username.trim() || !password) { setError('Please enter both your Habbo name and password.'); return; } if(turnstileEnabled && !loginTurnstileToken) { setError('Please complete the security check.'); return; } setError(null); setSubmitting(true); try { const { ok, payload } = await postJson(loginUrl, { username: username.trim(), password, remember: rememberMe, turnstileToken: turnstileEnabled ? loginTurnstileToken : undefined }); const ssoTicket = typeof payload.ssoTicket === 'string' ? payload.ssoTicket : (typeof payload.sso === 'string' ? payload.sso : ''); if(ok && ssoTicket) { clearLock(); if(rememberMe) StoreRememberLoginFromPayload(payload, typeof payload.username === 'string' ? payload.username : username.trim(), ssoTicket); else ClearRememberLogin(); onAuthenticated(ssoTicket); return; } recordFailure(); const message = typeof payload.error === 'string' ? payload.error : 'Invalid Habbo name or password.'; setError(message); resetLoginTurnstile(); } catch(err) { recordFailure(); setError('Unable to reach the login service. Please try again.'); resetLoginTurnstile(); } finally { setSubmitting(false); } }, [ submitting, isEntering, username, password, rememberMe, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile, pingLoginServer ]); const checkEmailUrl = GetConfigurationValue('login.check-email.endpoint', '/api/auth/check-email'); const checkUsernameUrl = GetConfigurationValue('login.check-username.endpoint', '/api/auth/check-username'); const imagingUrl = GetConfigurationValue('login.register.imaging.url', ''); const interpretAvailability = (ok: boolean, status: number, payload: Record): { available: boolean; error?: string } => { const isTrue = (v: unknown) => v === true || v === 'true' || v === 1 || v === '1'; const isFalse = (v: unknown) => v === false || v === 'false' || v === 0 || v === '0'; if(ok) { if(isTrue(payload.available) || isFalse(payload.exists) || isFalse(payload.taken) || isFalse(payload.inUse) || isFalse(payload.in_use)) return { available: true }; if(isFalse(payload.available) || isTrue(payload.exists) || isTrue(payload.taken) || isTrue(payload.inUse) || isTrue(payload.in_use)) return { available: false, error: typeof payload.error === 'string' ? payload.error : undefined }; return { available: true }; } if(status === 404 || status === 405 || status === 501) return { available: true }; if(status === 409) return { available: false, error: typeof payload.error === 'string' ? payload.error : undefined }; return { available: true }; }; const checkEmailAvailable = useCallback(async (email: string): Promise<{ available: boolean; error?: string }> => { try { const { ok, status, payload } = await postJson(checkEmailUrl, { email }); const result = interpretAvailability(ok, status, payload); if(result.available) return { available: true }; return { available: false, error: result.error || 'This email is already in use.' }; } catch { return { available: true }; } }, [ checkEmailUrl, postJson ]); const checkUsernameAvailable = useCallback(async (username: string): Promise<{ available: boolean; error?: string }> => { try { const { ok, status, payload } = await postJson(checkUsernameUrl, { username }); const result = interpretAvailability(ok, status, payload); if(result.available) return { available: true }; return { available: false, error: result.error || 'This Habbo name is already taken.' }; } catch { return { available: true }; } }, [ checkUsernameUrl, postJson ]); const handleRegisterSubmit = useCallback(async (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; }, onDialogReset: () => void) => { if(turnstileEnabled && !body.turnstileToken) { setError('Please complete the security check.'); return; } setError(null); setInfo(null); setSubmitting(true); try { const { ok, payload } = await postJson(registerUrl, { username: body.username, email: body.email, password: body.password, figure: body.figure, gender: body.gender, turnstileToken: turnstileEnabled ? body.turnstileToken : undefined }); if(ok) { const friendly = `Welcome aboard, ${ body.username }! Your account is ready — log in below with the password you just chose.`; setInfo(typeof payload.message === 'string' ? payload.message : friendly); setMode('login'); setUsername(body.username); setPassword(''); return; } setError(typeof payload.error === 'string' ? payload.error : 'Unable to create your account.'); onDialogReset(); } catch { setError('Unable to reach the registration service.'); onDialogReset(); } finally { setSubmitting(false); } }, [ turnstileEnabled, registerUrl, postJson ]); const handleForgotSubmit = useCallback(async (body: { email: string; turnstileToken: string; }, onDialogReset: () => void) => { if(turnstileEnabled && !body.turnstileToken) { setError('Please complete the security check.'); return; } setError(null); setInfo(null); setSubmitting(true); try { const { ok, payload } = await postJson(forgotUrl, { email: body.email, turnstileToken: turnstileEnabled ? body.turnstileToken : undefined }); if(ok) { const friendly = 'Email sent! If an account matches that address you\'ll find a reset link in your inbox shortly (check spam if it doesn\'t show up within a minute).'; setInfo(typeof payload.message === 'string' ? payload.message : friendly); setMode('login'); return; } setError(typeof payload.error === 'string' ? payload.error : 'Unable to send a reset email right now.'); onDialogReset(); } catch { setError('Unable to reach the password reset service.'); onDialogReset(); } finally { setSubmitting(false); } }, [ turnstileEnabled, forgotUrl, postJson ]); return (
{ background ? : null } { sun ? : null } { drape ? : null } { left ? : null } { rightRepeat ?
: null } { right ? : null } { loginWidgetSlots.length > 0 &&
{ loginWidgetSlots.map(slot => { const image = typeof slot.conf.image === 'string' ? interpolate(slot.conf.image) : ''; const texts = typeof slot.conf.texts === 'string' ? slot.conf.texts : ''; const btnText = typeof slot.conf.btnText === 'string' ? slot.conf.btnText : ''; const btnLink = typeof slot.conf.btnLink === 'string' ? interpolate(slot.conf.btnLink) : ''; const title = typeof slot.conf.title === 'string' ? slot.conf.title : (texts || slot.type); const description = typeof slot.conf.description === 'string' ? slot.conf.description : ''; return (
{ image && }
{ title }
{ description &&
{ description }
} { btnText && }
); }) }
}
Choose your language
{ LOGIN_LOCALES.map(locale => ) }
{ localeError.length > 0 &&
{ localeError }
}
First time here?
What's your Habbo called?
setUsername(e.target.value) } />
setPassword(e.target.value) } />
{ turnstileEnabled && mode === 'login' && setLoginTurnstileToken('') } onError={ () => setLoginTurnstileToken('') } resetSignal={ loginTurnstileResetSignal } /> } { loginServerReachable === false &&
The gameserver isn't running right now. Please try again in a moment.
} { error &&
{ error }
} { info &&
{ info }
}
setMode('forgot') }>Forgotten your password?
{ mode === 'register' && setMode('login') } onSubmit={ handleRegisterSubmit } onCheckEmail={ checkEmailAvailable } onCheckUsername={ checkUsernameAvailable } onCheckServer={ checkServerReachable } imagingUrl={ imagingUrl } submitting={ submitting } error={ error } info={ info } turnstileEnabled={ turnstileEnabled } turnstileSiteKey={ turnstileSiteKey } /> } { mode === 'forgot' && setMode('login') } onSubmit={ handleForgotSubmit } submitting={ submitting } error={ error } info={ info } turnstileEnabled={ turnstileEnabled } turnstileSiteKey={ turnstileSiteKey } /> }
); }; interface DialogSharedProps { onCancel: () => void; submitting: boolean; error: string | null; info: string | null; turnstileEnabled: boolean; turnstileSiteKey: string; } interface RegisterDialogProps extends DialogSharedProps { onSubmit: (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; }, onDialogReset: () => void) => void; onCheckEmail: (email: string) => Promise<{ available: boolean; error?: string }>; onCheckUsername: (username: string) => Promise<{ available: boolean; error?: string }>; onCheckServer: () => Promise; imagingUrl: string; } type RegisterStep = 'credentials' | 'avatar'; const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; type GenderKey = 'M' | 'F'; const PART_ROWS: string[] = [ 'hr', 'hd', 'ch', 'lg', 'sh' ]; const FALLBACK_DEFAULTS: Record> = { M: { hr: { partId: 180, colors: [ 45 ] }, hd: { partId: 180, colors: [ 1 ] }, ch: { partId: 215, colors: [ 66 ] }, lg: { partId: 270, colors: [ 82 ] }, sh: { partId: 290, colors: [ 80 ] } }, F: { hr: { partId: 515, colors: [ 45 ] }, hd: { partId: 600, colors: [ 1 ] }, ch: { partId: 660, colors: [ 100 ] }, lg: { partId: 716, colors: [ 82 ] }, sh: { partId: 725, colors: [ 61 ] } } }; const FALLBACK_HEX: Record = { 1: '#ffcb98', 8: '#f4ac54', 14: '#f5da88', 19: '#b87560', 20: '#9c543f', 45: '#e8c498', 61: '#f1ece3', 66: '#96743d', 80: '#4f4d4d', 82: '#7f4f30', 92: '#ececec', 100: '#c7ddff', 106: '#c6e6bd', 110: '#91a7c8', 143: '#ffffff' }; interface FigureColor { id: number; hexCode: string; club: number; selectable: boolean; } interface FigurePalette { id: number; colors: FigureColor[]; } interface FigureSet { id: number; gender: 'M' | 'F' | 'U'; club: number; selectable: boolean; } interface FigureSetType { type: string; paletteId: number; sets: FigureSet[]; } interface FigureData { palettes: FigurePalette[]; setTypes: FigureSetType[]; } interface PartSelection { partId: number; colors: number[]; } type FigureSelection = Record; const buildFigureString = (selection: FigureSelection): string => { const seen = new Set(); const parts: string[] = []; const push = (setType: string) => { if(seen.has(setType)) return; seen.add(setType); const sel = selection[setType]; if(!sel || sel.partId < 0) return; const tail = (sel.colors && sel.colors.length) ? `-${ sel.colors.join('-') }` : ''; parts.push(`${ setType }-${ sel.partId }${ tail }`); }; for(const setType of PART_ROWS) push(setType); for(const setType of Object.keys(selection)) push(setType); return parts.join('.'); }; const buildImagingUrl = (template: string, figure: string, gender: GenderKey): string => template .replace(/\{figure\}/g, encodeURIComponent(figure)) .replace(/\{gender\}/g, gender) .replace(/\{direction\}/g, '2'); const HEAD_ONLY_PARTS = new Set([ 'hr', 'hd' ]); const buildPartPreviewUrl = ( template: string, setType: string, selection: FigureSelection, gender: GenderKey ): string => { const defaults = FALLBACK_DEFAULTS[gender]; const partSel = selection[setType] ?? defaults[setType]; const tail = (partSel.colors && partSel.colors.length) ? `-${ partSel.colors.join('-') }` : ''; const isHeadOnly = HEAD_ONLY_PARTS.has(setType); let parts: string[]; if(isHeadOnly) { const hd = defaults.hd; const pieces = new Map(); pieces.set('hd', `hd-${ hd.partId }-${ hd.colors.join('-') }`); pieces.set(setType, `${ setType }-${ partSel.partId }${ tail }`); parts = Array.from(pieces.values()); } else { const hd = defaults.hd; parts = [ `hd-${ hd.partId }-${ hd.colors.join('-') }`, `${ setType }-${ partSel.partId }${ tail }` ]; } const figure = parts.join('.'); let url = template .replace(/\{figure\}/g, encodeURIComponent(figure)) .replace(/\{gender\}/g, gender) .replace(/\{direction\}/g, '2'); url = url.replace(/size=l/, 'size=s').replace(/size=m/, 'size=s'); if(!/size=/.test(url)) url += (url.includes('?') ? '&' : '?') + 'size=s'; if(isHeadOnly && !/headonly=/.test(url)) url += '&headonly=1'; return url; }; const RegisterDialog: FC = props => { const { onCancel, onSubmit, onCheckEmail, onCheckUsername, onCheckServer, imagingUrl, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props; const [ step, setStep ] = useState('credentials'); const [ email, setEmail ] = useState(''); const [ password, setPassword ] = useState(''); const [ confirm, setConfirm ] = useState(''); const [ username, setUsername ] = useState(''); const [ gender, setGender ] = useState('F'); const [ selection, setSelection ] = useState(() => ({ ...FALLBACK_DEFAULTS.F })); const [ localError, setLocalError ] = useState(null); const [ checking, setChecking ] = useState(false); const [ turnstileToken, setTurnstileToken ] = useState(''); const [ resetSignal, setResetSignal ] = useState(0); const [ serverReachable, setServerReachable ] = useState(null); const [ pingingServer, setPingingServer ] = useState(false); const pingServer = useCallback(async () => { setPingingServer(true); try { const ok = await onCheckServer(); setServerReachable(ok); return ok; } finally { setPingingServer(false); } }, [ onCheckServer ]); useEffect(() => { let cancelled = false; (async () => { const ok = await onCheckServer(); if(!cancelled) setServerReachable(ok); })(); return () => { cancelled = true; }; }, [ onCheckServer ]); const resetWidget = useCallback(() => { setTurnstileToken(''); setResetSignal(prev => prev + 1); }, []); useEffect(() => { setLocalError(null); }, [ step ]); const [ figureData, setFigureData ] = useState(null); const figureDataUrlRaw = GetConfigurationValue('avatar.figuredata.url', ''); const figureDataUrl = useMemo(() => { if(!figureDataUrlRaw) return ''; try { return GetConfiguration().interpolate(figureDataUrlRaw); } catch { return figureDataUrlRaw; } }, [ figureDataUrlRaw ]); useEffect(() => { if(step !== 'avatar' || figureData || !figureDataUrl) return; let cancelled = false; fetch(figureDataUrl, { credentials: 'omit' }) .then(r => r.ok ? r.json() : null) .then(json => { if(!cancelled && json) setFigureData(json as FigureData); }) .catch(() => { }); return () => { cancelled = true; }; }, [ step, figureData, figureDataUrl ]); const partOptions = useMemo(() => { const result: Record> = {}; if(!figureData) return result; for(const st of figureData.setTypes) { if(!PART_ROWS.includes(st.type)) continue; const forGender = (g: GenderKey) => st.sets .filter(s => s.selectable && s.club === 0 && (s.gender === g || s.gender === 'U')) .map(s => s.id); result[st.type] = { M: forGender('M'), F: forGender('F') }; } return result; }, [ figureData ]); const paletteOptions = useMemo(() => { const result: Record = {}; if(!figureData) return result; for(const st of figureData.setTypes) { if(!PART_ROWS.includes(st.type)) continue; const palette = figureData.palettes.find(p => p.id === st.paletteId); if(!palette) { result[st.type] = []; continue; } result[st.type] = palette.colors .filter(c => c.selectable && c.club === 0) .map(c => ({ id: c.id, hex: '#' + c.hexCode.toUpperCase() })); } return result; }, [ figureData ]); const hexFor = useCallback((setType: string, colorId: number): string => { const list = paletteOptions[setType]; if(list) { const found = list.find(c => c.id === colorId); if(found) return found.hex; } return FALLBACK_HEX[colorId] || '#c9c9c9'; }, [ paletteOptions ]); const [ hotLooks, setHotLooks ] = useState<{ gender: GenderKey; figure: string }[]>([]); const [ hotLookIndex, setHotLookIndex ] = useState(-1); useEffect(() => { if(step !== 'avatar' || hotLooks.length) return; let cancelled = false; fetch(configFileUrl('hotlooks.json', true), { credentials: 'omit' }) .then(r => r.ok ? r.json() : null) .then((json: unknown) => { if(cancelled || !Array.isArray(json)) return; const parsed: { gender: GenderKey; figure: string }[] = []; for(const entry of json as Record[]) { const rawGender = typeof entry._gender === 'string' ? entry._gender.toUpperCase() : ''; const figure = typeof entry._figure === 'string' ? entry._figure : ''; if((rawGender !== 'M' && rawGender !== 'F') || !figure) continue; parsed.push({ gender: rawGender as GenderKey, figure }); } if(parsed.length) setHotLooks(parsed); }) .catch(() => { }); return () => { cancelled = true; }; }, [ step, hotLooks.length ]); const applyLook = useCallback((figure: string, lookGender: GenderKey) => { const next: FigureSelection = {}; for(const setPart of figure.split('.')) { const bits = setPart.split('-'); if(bits.length < 2) continue; const setType = bits[0]; const partId = parseInt(bits[1], 10); if(!setType || Number.isNaN(partId)) continue; const colors: number[] = []; for(let i = 2; i < bits.length; i++) { const c = parseInt(bits[i], 10); if(!Number.isNaN(c)) colors.push(c); } next[setType] = { partId, colors }; } for(const setType of PART_ROWS) { if(!next[setType]) next[setType] = { ...FALLBACK_DEFAULTS[lookGender][setType] }; } setGender(lookGender); setSelection(next); }, []); const cycleHotLook = useCallback(() => { if(!hotLooks.length) return; const nextIdx = (hotLookIndex + 1) % hotLooks.length; setHotLookIndex(nextIdx); const look = hotLooks[nextIdx]; applyLook(look.figure, look.gender); }, [ hotLooks, hotLookIndex, applyLook ]); const credentialsValid = EMAIL_REGEX.test(email.trim()) && password.length >= 8 && password === confirm; const handleCredentialsNext = async (event: FormEvent) => { event.preventDefault(); setLocalError(null); if(!email.trim() || !password || !confirm) { setLocalError('Please fill in every field.'); return; } if(!EMAIL_REGEX.test(email.trim())) { setLocalError('Please enter a valid email address.'); return; } if(password.length < 8) { setLocalError('Your password must be at least 8 characters.'); return; } if(password !== confirm) { setLocalError('Passwords do not match.'); return; } setChecking(true); try { const serverOk = await pingServer(); if(!serverOk) { setLocalError('The gameserver is not running. Please try again later.'); return; } const result = await onCheckEmail(email.trim()); if(!result.available) { setLocalError(result.error || 'This email is already in use.'); return; } setStep('avatar'); } finally { setChecking(false); } }; const applyGender = (newGender: GenderKey) => { setGender(newGender); setSelection({ ...FALLBACK_DEFAULTS[newGender] }); setHotLookIndex(-1); }; const getPartList = useCallback((setType: string): number[] => { const loaded = partOptions[setType]?.[gender]; if(loaded && loaded.length) return loaded; const fallback = FALLBACK_DEFAULTS[gender][setType]?.partId; return fallback !== undefined ? [ fallback ] : []; }, [ partOptions, gender ]); const getColorList = useCallback((setType: string): number[] => { const loaded = paletteOptions[setType]; if(loaded && loaded.length) return loaded.map(c => c.id); const fallback = FALLBACK_DEFAULTS[gender][setType]?.colors?.[0]; return fallback !== undefined ? [ fallback ] : []; }, [ paletteOptions, gender ]); const cyclePart = (setType: string, direction: 1 | -1) => { const options = getPartList(setType); if(!options.length) return; const current = selection[setType]?.partId ?? options[0]; const idx = options.indexOf(current); const nextIdx = ((idx === -1 ? 0 : idx) + direction + options.length) % options.length; const colors = getColorList(setType); setSelection(prev => ({ ...prev, [setType]: { partId: options[nextIdx], colors: prev[setType]?.colors ?? [ colors[0] ?? 0 ] } })); }; const cycleColor = (setType: string, direction: 1 | -1) => { const colors = getColorList(setType); if(!colors.length) return; const currentColor = selection[setType]?.colors?.[0] ?? colors[0]; const idx = colors.indexOf(currentColor); const nextIdx = ((idx === -1 ? 0 : idx) + direction + colors.length) % colors.length; const parts = getPartList(setType); setSelection(prev => ({ ...prev, [setType]: { partId: prev[setType]?.partId ?? parts[0], colors: [ colors[nextIdx] ] } })); }; const figure = buildFigureString(selection); const previewSrc = buildImagingUrl(imagingUrl, figure, gender); const handleAvatarSubmit = async (event: FormEvent) => { event.preventDefault(); setLocalError(null); const trimmed = username.trim(); if(!trimmed) { setLocalError('Please choose a Habbo name.'); return; } if(trimmed.length < 3 || trimmed.length > 16) { setLocalError('Habbo name must be 3–16 characters.'); return; } if(turnstileEnabled && !turnstileToken) { setLocalError('Please complete the security check.'); return; } setChecking(true); try { const serverOk = await pingServer(); if(!serverOk) { setLocalError('The gameserver is not running. Please try again later.'); return; } const result = await onCheckUsername(trimmed); if(!result.available) { setLocalError(result.error || 'This Habbo name is already taken.'); return; } } finally { setChecking(false); } onSubmit({ username: trimmed, email: email.trim(), password, figure, gender, turnstileToken }, resetWidget); }; const busy = submitting || checking || pingingServer; const serverOffline = serverReachable === false; return (
Habbo Details
{ step === 'credentials' &&
Let's create your account. Enter your email and pick a password — we'll check that email isn't already in use.
{ serverOffline &&
The gameserver isn't running right now, so new accounts can't be created. Please try again in a moment.
}
setEmail(e.target.value) } />
setPassword(e.target.value) } />
setConfirm(e.target.value) } />
{ (localError || error) &&
{ localError || error }
} { info &&
{ info }
}
1/2
} { step === 'avatar' &&
Now it's time to make your own Habbo character! To make your own Habbo, please start by choosing your Habbo Name.
{ serverOffline &&
The gameserver isn't running right now, so new accounts can't be created. Please try again in a moment.
}
setUsername(e.target.value) } />
{ PART_ROWS.map(setType => { const partPreviewSrc = buildPartPreviewUrl(imagingUrl, setType, selection, gender); return (
{ { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } />
); }) }
Habbo preview { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } />
{ PART_ROWS.map(setType => { const fallbackColor = FALLBACK_DEFAULTS[gender][setType]?.colors?.[0] ?? 0; const currentColor = selection[setType]?.colors?.[0] ?? fallbackColor; const swatchHex = hexFor(setType, currentColor); return (
); }) }
{ turnstileEnabled && setTurnstileToken('') } onError={ () => setTurnstileToken('') } resetSignal={ resetSignal } /> } { (localError || error) &&
{ localError || error }
} { info &&
{ info }
}
2/2
}
); }; interface ForgotDialogProps extends DialogSharedProps { onSubmit: (body: { email: string; turnstileToken: string; }, onDialogReset: () => void) => void; } const ForgotDialog: FC = props => { const { onCancel, onSubmit, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props; const [ email, setEmail ] = useState(''); const [ localError, setLocalError ] = useState(null); const [ turnstileToken, setTurnstileToken ] = useState(''); const [ resetSignal, setResetSignal ] = useState(0); const resetWidget = useCallback(() => { setTurnstileToken(''); setResetSignal(prev => prev + 1); }, []); const handle = (event: FormEvent) => { event.preventDefault(); setLocalError(null); if(!email.trim()) { setLocalError('Please enter your email address.'); return; } onSubmit({ email: email.trim(), turnstileToken }, resetWidget); }; return (
Reset password
setEmail(e.target.value) } />
{ turnstileEnabled && setTurnstileToken('') } onError={ () => setTurnstileToken('') } resetSignal={ resetSignal } /> } { (localError || error) &&
{ localError || error }
} { info &&
{ info }
}
); };