diff --git a/public/hotlooks.json b/public/hotlooks.json new file mode 100644 index 0000000..cda5717 --- /dev/null +++ b/public/hotlooks.json @@ -0,0 +1,52 @@ +[ + { + "_gender": "m", + "_figure": "hr-155-40.hd-180-10.ch-255-1408.lg-280-64.sh-290-64.ha-1003-64", + "_hash": "b5d1a24d16c9d516b3d793c66d152b77" + }, + { + "_gender": "f", + "_figure": "hr-515-34.hd-629-8.ch-665-1408.lg-715-1320.sh-740-1408.he-1608", + "_hash": "694573ec86cf5346f1c88b1017f069f8" + }, + { + "_gender": "f", + "_figure": "hr-890-36.hd-629-8.ch-685-71.lg-715-71.sh-3068-71-73.ha-1018.fa-1202-71.ca-1802", + "_hash": "10b9a935209e6c213e54108474186dc8" + }, + { + "_gender": "m", + "_figure": "hr-115-42.hd-209-1.ch-255-73.lg-3078-82.sh-300-64", + "_hash": "1457ce2369b982bcce30e8307c005d98" + }, + { + "_gender": "m", + "_figure": "hr-115-40.hd-190-14.ch-235-1408.lg-280-1408.sh-908-1408.he-1608", + "_hash": "d35b7492386963d7612341b222f7f5d9" + }, + { + "_gender": "m", + "_figure": "hr-115-31.hd-180-14.ch-210-64.lg-3023-91.sh-300-91", + "_hash": "b49e529b7604fbd3596951bc69d6551b" + }, + { + "_gender": "m", + "_figure": "hr-100.hd-180-1.ch-210-1408.lg-270-64.sh-300-64.ha-1002-64.cc-260-64", + "_hash": "f052b0ccc54cfa933d473b433b154ef5" + }, + { + "_gender": "m", + "_figure": "hr-125-34.hd-205-14.ch-235-1408.lg-285-81.sh-300-64.wa-3211-64-64", + "_hash": "08c77292a4462c36f0393820d5753de3" + }, + { + "_gender": "f", + "_figure": "hr-890-31.hd-600-1.ch-822-71.lg-715-74.he-1602-71", + "_hash": "4102a76da4bca25d5125b75d9ea1ca14" + }, + { + "_gender": "f", + "_figure": "hr-515-35.hd-628-14.ch-667.lg-696-73.he-1606-82.ca-1810.cp-3124-81", + "_hash": "4987ff565ec8e6ecedb31b08c2b017a6" + } +] \ No newline at end of file diff --git a/src/components/login/LoginView.tsx b/src/components/login/LoginView.tsx index 8116373..7ab8457 100644 --- a/src/components/login/LoginView.tsx +++ b/src/components/login/LoginView.tsx @@ -14,8 +14,8 @@ const interpolate = (value: string | null | undefined): string => const LOCK_KEY = 'nitro.login.lock'; const MAX_ATTEMPTS = 5; -const LOCK_WINDOW_MS = 60_000; // rolling 60s window -const LOCK_DURATION_MS = 2 * 60_000; // 2 minute lockout +const LOCK_WINDOW_MS = 60_000; +const LOCK_DURATION_MS = 2 * 60_000; type AttemptState = { attempts: number; firstAt: number; lockedUntil: number }; @@ -33,7 +33,7 @@ const readLock = (): AttemptState => const writeLock = (state: AttemptState) => { try { sessionStorage.setItem(LOCK_KEY, JSON.stringify(state)); } - catch { /* ignore */ } + catch { } }; export interface LoginViewProps @@ -51,6 +51,8 @@ export const LoginView: FC = ({ onAuthenticated }) => 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 loginImages: Record = ((GetConfigurationValue>('loginview', {})?.['images']) as Record) ?? {}; @@ -62,22 +64,6 @@ export const LoginView: FC = ({ onAuthenticated }) => 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', '')); - - useEffect(() => - { - // eslint-disable-next-line no-console - console.info('[LoginView] resolved background assets', { - 'asset.url': GetConfigurationValue('asset.url', ''), - login_background: background, - 'login_background.colour': backgroundColor, - login_sun: sun, - login_drape: drape, - login_left: left, - login_right: right, - 'login_right.repeat': rightRepeat - }); - }, [ background, backgroundColor, sun, drape, left, right, rightRepeat ]); - 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'); @@ -88,33 +74,18 @@ export const LoginView: FC = ({ onAuthenticated }) => || rawTurnstileEnabled === 1 || rawTurnstileEnabled === '1') && !!turnstileSiteKey; - useEffect(() => - { - // eslint-disable-next-line no-console - console.info('[LoginView] turnstile config', { - rawTurnstileEnabled, - turnstileEnabled, - turnstileSiteKey: turnstileSiteKey ? (turnstileSiteKey.slice(0, 6) + '…') : '(empty)' - }); - }, [ rawTurnstileEnabled, turnstileEnabled, turnstileSiteKey ]); - const resetLoginTurnstile = useCallback(() => { setLoginTurnstileToken(''); setLoginTurnstileResetSignal(prev => prev + 1); }, []); - // Clear error on mode change but keep the success notification so users - // returning to the login form can read it (e.g. "Account created"). - // Reset the login captcha only when we're actually on the login form. useEffect(() => { setError(null); if(mode === 'login') resetLoginTurnstile(); }, [ mode, resetLoginTurnstile ]); - // Auto-dismiss the info notification after a few seconds so it doesn't - // hang around forever once the user has seen it. useEffect(() => { if(!info) return; @@ -162,11 +133,65 @@ export const LoginView: FC = ({ onAuthenticated }) => let payload: Record = {}; try { payload = await response.json(); } - catch { /* ignore non-json responses */ } + catch { } return { ok: response.ok, status: response.status, payload }; }, []); + const healthUrl = GetConfigurationValue('login.health.endpoint', '/api/health'); + 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(); @@ -202,6 +227,12 @@ export const LoginView: FC = ({ onAuthenticated }) => 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, @@ -232,14 +263,60 @@ export const LoginView: FC = ({ onAuthenticated }) => { setSubmitting(false); } - }, [ submitting, username, password, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile ]); + }, [ submitting, username, password, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile, pingLoginServer ]); - // Register + forgot-password submit handlers receive the Turnstile token - // from the dialog (the dialog owns its own widget lifecycle), so the - // login widget underneath can't reset or overwrite it while the user - // is working on the modal. + 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'; - const handleRegisterSubmit = useCallback(async (body: { username: string; email: string; password: string; turnstileToken: string; }, onDialogReset: () => void) => + 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) { @@ -257,6 +334,8 @@ export const LoginView: FC = ({ onAuthenticated }) => username: body.username, email: body.email, password: body.password, + figure: body.figure, + gender: body.gender, turnstileToken: turnstileEnabled ? body.turnstileToken : undefined }); @@ -382,14 +461,22 @@ export const LoginView: FC = ({ onAuthenticated }) => onError={ () => setLoginTurnstileToken('') } resetSignal={ loginTurnstileResetSignal } /> } + { loginServerReachable === false && +
+ The gameserver isn't running right now. Please try again in a moment. + +
+ } { error &&
{ error }
} { info &&
{ info }
}
+ disabled={ submitting || isLocked || loginServerReachable === false || loginPingingServer } + >{ loginPingingServer ? 'Checking…' : 'OK' }
setMode('forgot') }>Forgotten your password? @@ -400,6 +487,10 @@ export const LoginView: FC = ({ onAuthenticated }) => setMode('login') } onSubmit={ handleRegisterSubmit } + onCheckEmail={ checkEmailAvailable } + onCheckUsername={ checkUsernameAvailable } + onCheckServer={ checkServerReachable } + imagingUrl={ imagingUrl } submitting={ submitting } error={ error } info={ info } @@ -433,19 +524,165 @@ interface DialogSharedProps interface RegisterDialogProps extends DialogSharedProps { - onSubmit: (body: { username: string; email: string; password: string; turnstileToken: string; }, onDialogReset: () => void) => void; + 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, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props; - const [ username, setUsername ] = useState(''); + 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(() => { @@ -453,82 +690,457 @@ const RegisterDialog: FC = props => setResetSignal(prev => prev + 1); }, []); - const handle = (event: FormEvent) => + 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(!username.trim() || !email.trim() || !password) + 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; } - onSubmit({ username: username.trim(), email: email.trim(), password, turnstileToken }, resetWidget); + 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 (
-
+
- Create a Habbo + Habbo Details
-
-
- - setUsername(e.target.value) } /> -
-
- - setEmail(e.target.value) } /> -
-
- - setPassword(e.target.value) } /> -
-
- - setConfirm(e.target.value) } /> -
- { turnstileEnabled && - setTurnstileToken('') } - onError={ () => setTurnstileToken('') } - resetSignal={ resetSignal } - /> } - { (localError || error) &&
{ localError || error }
} - { info &&
{ info }
} -
- -
- + + { 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; diff --git a/src/css/login/LoginView.css b/src/css/login/LoginView.css index c2f15d8..984a68f 100644 --- a/src/css/login/LoginView.css +++ b/src/css/login/LoginView.css @@ -1,18 +1,3 @@ -/* ─── Classic Login View ───────────────────────────────────────────────── - Port of the old Nitro HotelView background layering, used exclusively by - the login screen. Assets are driven by ui-config.json: - loginview.images.background → .login-background - loginview.images.background.colour → .nitro-login-view base colour - loginview.images.sun → .login-sun - loginview.images.drape → .login-drape - loginview.images.left → .login-left - loginview.images.right → .login-right - loginview.images.right.repeat → .login-right-repeat - - Class names are deliberately prefixed so HotelView.css rules - (.left { left: 18vw !important } etc.) cannot clobber us. - --------------------------------------------------------------------- */ - .nitro-login-view { position: fixed; inset: 0; @@ -82,7 +67,7 @@ background-position: right bottom; } -/* ─── Foreground Login Card Stack ───────────────────────────────────── */ +/* ─── Foreground Login Card Stack ─── */ .nitro-login-view .login-stack { position: absolute; @@ -242,8 +227,6 @@ max-width: 100%; } -/* Modal overlay used for register + forgot password dialogs */ - .nitro-login-modal { position: fixed; inset: 0; @@ -259,3 +242,215 @@ max-width: calc(100% - 40px); } +.nitro-login-modal .dialog.dialog-avatar { + width: 400px; +} + +/* ─── Multi-step register dialog ─── */ + +.nitro-login-card .register-intro { + background: #eef4f8; + border: 1px solid #b6cfdd; + border-radius: 4px; + padding: 6px 8px; + font-size: 11px; + line-height: 1.4; + color: #0a2e45; +} + +.nitro-login-card .step-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + margin-top: 4px; +} + +.nitro-login-card .step-footer-split { + justify-content: space-between; +} + +.nitro-login-card .step-indicator { + font-size: 11px; + color: #134b6e; + font-weight: 600; +} + +.nitro-login-card .back-button { + background: #d5e2eb; +} + +/* ─── Avatar builder (pre-login) ─── */ + +.nitro-login-card .gender-row { + display: flex; + justify-content: center; + gap: 22px; + font-size: 11px; + font-weight: 600; +} + +.nitro-login-card .gender-row label { + display: inline-flex; + align-items: center; + gap: 4px; + cursor: pointer; +} + +.nitro-login-card .avatar-builder { + display: grid; + grid-template-columns: 74px 1fr 74px; + gap: 6px; + align-items: stretch; + background: repeating-linear-gradient( + 0deg, + #ffffff 0, + #ffffff 8px, + #e5ecf1 8px, + #e5ecf1 16px + ); + border: 1px solid #7595ac; + border-radius: 6px; + padding: 6px; +} + +.nitro-login-card .avatar-part-col, +.nitro-login-card .avatar-color-col { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 4px; +} + +.nitro-login-card .avatar-part-row, +.nitro-login-card .avatar-color-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 3px; + min-height: 44px; +} + +.nitro-login-card .arrow-btn { + width: 16px; + height: 20px; + line-height: 1; + padding: 0; + border: 1px solid #7595ac; + border-radius: 3px; + background: #ffffff; + color: #0a2e45; + font-size: 14px; + font-weight: 700; + cursor: pointer; + box-shadow: inset 0 1px rgba(255, 255, 255, 0.8), 0 1px rgba(0, 0, 0, 0.15); + flex-shrink: 0; +} + +.nitro-login-card .arrow-btn:hover { + background: #e9f1f7; +} + +.nitro-login-card .part-preview { + flex: 1; + height: 44px; + border: 1px solid #7595ac; + border-radius: 3px; + background: #ffffff; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + box-shadow: inset 0 1px rgba(255, 255, 255, 0.8); +} + +.nitro-login-card .part-preview img { + image-rendering: pixelated; + image-rendering: -moz-crisp-edges; + pointer-events: none; + user-select: none; + max-width: none; + height: auto; +} + +.nitro-login-card .part-preview-hr img, +.nitro-login-card .part-preview-hd img { + width: 40px; + height: auto; +} + +.nitro-login-card .part-preview-ch img { + width: 50px; + margin-top: 8px; +} + +.nitro-login-card .part-preview-lg img { + width: 50px; + margin-top: -8px; +} + +.nitro-login-card .part-preview-sh img { + width: 50px; + margin-top: -22px; +} + +.nitro-login-card .color-swatch { + flex: 1; + height: 18px; + border: 1px solid #7595ac; + border-radius: 3px; + box-shadow: inset 0 1px rgba(255, 255, 255, 0.4); +} + +.nitro-login-card .avatar-preview { + display: flex; + align-items: flex-end; + justify-content: center; + min-height: 130px; + overflow: hidden; +} + +.nitro-login-card .avatar-preview img { + max-width: 100%; + max-height: 140px; + image-rendering: pixelated; + image-rendering: -moz-crisp-edges; +} + +.nitro-login-card .hot-looks-row { + display: flex; + justify-content: center; + margin-top: 2px; +} + +.nitro-login-card .server-offline { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 6px; + text-align: left; +} + +.nitro-login-card .server-offline .retry-link { + background: #ffffff; + border: 1px solid #3f6a85; + border-radius: 4px; + padding: 2px 10px; + font-size: 11px; + font-weight: 700; + color: #0a2e45; + cursor: pointer; + box-shadow: inset 0 1px rgba(255, 255, 255, 0.8), 0 1px rgba(0, 0, 0, 0.15); +} + +.nitro-login-card .server-offline .retry-link:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.nitro-login-card .hot-looks-button { + padding: 4px 14px; + font-size: 11px; +} +