import { GetConfiguration } from '@nitrots/nitro-renderer'; import { FC, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { GetConfigurationValue } from '../../api'; import { TurnstileWidget } from './TurnstileWidget'; type DialogMode = 'login' | 'register' | 'forgot'; const interpolate = (value: string | null | undefined): string => { if(!value) return ''; try { return GetConfiguration().interpolate(value); } catch { return value; } }; const LOCK_KEY = 'nitro.login.lock'; const MAX_ATTEMPTS = 5; const LOCK_WINDOW_MS = 60_000; const LOCK_DURATION_MS = 2 * 60_000; const DEFAULT_LOGIN_IMAGES: Record = { background: 'https://hotel.slogga.it/client/nitro/images/reception/background_gradient_apr25.png', 'background.colour': '#6eadc8', drape: 'https://hotel.slogga.it/client/nitro/images/reception/drape.png', left: 'https://hotel.slogga.it/client/nitro/images/reception/mute_reception_backdrop_left.png', right: 'https://hotel.slogga.it/client/nitro/images/reception/background_right.png' }; 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 { } }; 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 submitTimeRef = useRef(0); const configuredLoginImages: Record = ((GetConfigurationValue>('loginview', {})?.['images']) as Record) ?? {}; const loginImages: Record = { ...DEFAULT_LOGIN_IMAGES, ...configuredLoginImages }; 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 loginImageUrls = useMemo(() => [ background, sun, drape, left, rightRepeat, right ].filter(Boolean), [ background, sun, drape, left, rightRepeat, right ]); 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(() => { 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 ]); useEffect(() => { let cancelled = false; (async () => { const ok = await checkServerReachable(); if(!cancelled) setLoginServerReachable(ok); })(); return () => { cancelled = true; }; }, [ 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 serverOk = await pingLoginServer(); if(!serverOk) { setError('The gameserver is not running. Please try again later.'); return; } const { ok, payload } = await postJson(loginUrl, { username: username.trim(), password, turnstileToken: turnstileEnabled ? loginTurnstileToken : undefined }); const ssoTicket = typeof payload.ssoTicket === 'string' ? payload.ssoTicket : (typeof payload.sso === 'string' ? payload.sso : ''); if(ok && ssoTicket) { clearLock(); 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, 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', 'https://www.habbo.com/habbo-imaging/avatarimage?figure={figure}&gender={gender}&direction=2&head_direction=2&size=l'); 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 }
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('hotlooks.json', { 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 }
}
); };