5180 fichiers 38.5 Mo 0 téléchargements 252 vues mis à jour il y a 5 heures ui
LoginView.tsx 81.9 Ko Télécharger le fichier
import { GetAvatarRenderManager, GetConfiguration } from '@octane/renderer';
import { FC, useActionState, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useFormStatus } from 'react-dom';
import { FaDice } from 'react-icons/fa';
import { ClearRememberLogin, GetConfigurationValue, GetOptionalConfigurationValue, GetRememberLogin, isRememberCookieMode, persistAccessTokenFromPayload, StoreRememberLoginFromPayload } from '../../api';
import flagEn from '../../assets/images/flag_icon/flag_icon_en.png';
import flagEs from '../../assets/images/flag_icon/flag_icon_es.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 flagDe from '../../assets/images/flag_icon/flag_icon_de.png';
import flagAr from '../../assets/images/flag_icon/flag_icon_ar.png';
import { applyTextTranslationLocale } from '../../hooks/translation/useTranslation';
import { configFileUrl } from '../../secure-assets';
import { LoginModal } from './components/LoginModal';
import { LoginWidgetSlot } from './components/LoginWidgetSlot';
import { NewsWindow } from './components/NewsWindow';
import { useDraggableLoginWindow } from './hooks/useDraggableLoginWindow';
import { RegistrationAvatarWardrobe } from './components/RegistrationAvatarWardrobe';
import { TurnstileWidget } from './TurnstileWidget';
import { t } from './utils/i18n';
import { clearRegistrationDraft, loadRegistrationDraft, saveRegistrationDraft } from './utils/registrationDraft';
import { Checkbox } from '../../common/Checkbox';
import { Radio } from '../../common/Radio';
import { Select } from '../../common/Select';
import { BaseButton } from '../../common/BaseButton';
import { Hint } from '../../common/Tooltip';
import { NativeTextTree } from '../../common/native-text/NativeTextTree';

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).OctaneSecureApiUrl === 'string') {
            const secureApiUrl = (window as any).OctaneSecureApiUrl.replace(/\/$/, '');

            if (secureApiUrl) return secureApiUrl;
        }

        try {
            const configValue = GetConfiguration().getValue<string>(key, '');

            if (configValue) return configValue;
        } catch {}

        try {
            const configValue = GetConfigurationValue<string>(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<string, string> => {
    const imagesBase = (GetConfigurationValue<string>('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: 'nl', file: 'nl', label: 'Nederlands', flag: flagNl },
    { code: 'de', file: 'de', label: 'Deutsch', flag: flagDe },
    { code: 'ar', file: 'ar', label: 'العربية', flag: flagAr }
];

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

const LoginSubmitButton: FC<{ isEntering: boolean; isLocked: boolean; loginPingingServer: boolean }> = ({ isEntering, isLocked, loginPingingServer }) => {
    const { pending } = useFormStatus();

    return (
        <NativeTextTree>
            <BaseButton type="submit" className="ok-button" disabled={pending || isEntering || isLocked}>
                {isEntering
                    ? t('nitro.login.entering', 'Entering…')
                    : pending || loginPingingServer
                      ? t('nitro.login.server.checking', 'Checking…')
                      : t('login.title', 'Log in')}
            </BaseButton>
        </NativeTextTree>
    );
};

export interface LoginViewProps {
    onAuthenticated: (ssoTicket: string) => void;
    isEntering?: boolean;
}

export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = false }) => {
    const [mode, setMode] = useState<DialogMode>('login');
    const [username, setUsername] = useState('');
    const [password, setPassword] = useState('');
    const [error, setError] = useState<string | null>(null);
    const [maintenance, setMaintenance] = useState<{ enabled: boolean; message: string } | null>(null);
    const [info, setInfo] = useState<string | null>(null);
    const [submitting, setSubmitting] = useState(false);
    const [loginTurnstileToken, setLoginTurnstileToken] = useState('');
    const [loginTurnstileResetSignal, setLoginTurnstileResetSignal] = useState(0);
    const [loginServerReachable, setLoginServerReachable] = useState<boolean | null>(null);
    const [loginPingingServer, setLoginPingingServer] = useState(false);
    const [rememberMe, setRememberMe] = useState(() => !!GetRememberLogin());
    const [selectedLocale, setSelectedLocale] = useState<LoginLocale>(() => readCachedLocale());
    const [localeApplying, setLocaleApplying] = useState(false);
    const [localeError, setLocaleError] = useState('');
    const initialLocaleRef = useRef(selectedLocale);
    const [loginViewConfig, setLoginViewConfig] = useState<Record<string, unknown>>(() => GetConfigurationValue<Record<string, unknown>>('loginview', {}));
    const submitTimeRef = useRef(0);
    const preloadedLoginImagesRef = useRef<Set<string>>(new Set());

    const configuredLoginImages = useMemo<Record<string, string>>(() => (loginViewConfig?.['images'] as Record<string, string>) ?? {}, [loginViewConfig]);
    const loginImages = useMemo<Record<string, string>>(() => ({ ...getDefaultLoginImages(), ...configuredLoginImages }), [configuredLoginImages]);

    const configuredLoginWidgets = useMemo<Record<string, unknown>>(() => (loginViewConfig?.['widgets'] as Record<string, unknown>) ?? {}, [loginViewConfig]);

    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<string, unknown>) ?? {};

                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<string>('login_background.colour', '#6eadc8');
    const background = interpolate(loginImages['background'] || GetConfigurationValue<string>('login_background', ''));
    const sun = interpolate(loginImages['sun'] || GetConfigurationValue<string>('login_sun', ''));
    const drape = interpolate(loginImages['drape'] || GetConfigurationValue<string>('login_drape', ''));
    const left = interpolate(loginImages['left'] || GetConfigurationValue<string>('login_left', ''));
    const rightRepeat = interpolate(loginImages['right.repeat'] || GetConfigurationValue<string>('login_right.repeat', ''));
    const right = interpolate(loginImages['right'] || GetConfigurationValue<string>('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<string>('login.endpoint', '/api/auth/login');
    const registerUrl = GetConfigurationValue<string>('login.register.endpoint', '/api/auth/register');
    const roomTemplatesUrl = GetConfigurationValue<string>('login.room_templates.endpoint', '/api/auth/room-templates');
    const forgotUrl = GetConfigurationValue<string>('login.forgot.endpoint', '/api/auth/forgot-password');
    const newsUrl = interpolate(GetConfigurationValue<string>('login.news.url', ''));
    const dragHint = t('nitro.login.window.drag_hint', 'Drag to move, double-click to reset');
    const languageWindowRef = useRef<HTMLDivElement>(null);
    const authWindowRef = useRef<HTMLDivElement>(null);
    const registerWindowRef = useRef<HTMLDivElement>(null);
    const languageWindow = useDraggableLoginWindow('language', languageWindowRef);
    const authWindow = useDraggableLoginWindow('auth', authWindowRef);
    const registerWindow = useDraggableLoginWindow('register', registerWindowRef);
    const turnstileSiteKey = GetConfigurationValue<string>('login.turnstile.sitekey', '');
    const rawTurnstileEnabled = GetConfigurationValue<unknown>('login.turnstile.enabled', false);
    const turnstileEnabled =
        (rawTurnstileEnabled === true || rawTurnstileEnabled === 'true' || rawTurnstileEnabled === 1 || rawTurnstileEnabled === '1') && !!turnstileSiteKey;

    const resetLoginTurnstile = useCallback(() => {
        setLoginTurnstileToken('');
        setLoginTurnstileResetSignal((prev) => prev + 1);
    }, []);

    useEffect(() => {
        let cancelled = false;
        const locale = initialLocaleRef.current;

        setLocaleApplying(true);

        void applyTextTranslationLocale(locale.code)
            .catch(() => {
                if (!cancelled) setLocaleError('Unable to load this language pack.');
            })
            .finally(() => {
                if (!cancelled) setLocaleApplying(false);
            });

        return () => {
            cancelled = true;
        };
    }, []);

    const closeDialog = useCallback(() => setMode('login'), []);

    useEffect(() => {
        setError(null);
        if (mode === 'login') resetLoginTurnstile();
    }, [mode, resetLoginTurnstile]);

    useEffect(() => {
        let cancelled = false;

        const refreshLoginViewConfig = () => {
            if (cancelled) return;

            const nextConfig = GetConfigurationValue<Record<string, unknown>>('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 changeLocaleSelection = useCallback(
        async (localeCode: string) => {
            const locale = LOGIN_LOCALES.find((candidate) => candidate.code === localeCode);

            if (!locale || localeApplying) return;

            const previousLocale = selectedLocale;

            setSelectedLocale(locale);
            setLocaleApplying(true);
            setLocaleError('');

            try {
                await applyTextTranslationLocale(locale.code);
                applyLocaleSelection(locale);
            } catch {
                setSelectedLocale(previousLocale);
                setLocaleError('Unable to load this language pack.');
            } finally {
                setLocaleApplying(false);
            }
        },
        [localeApplying, selectedLocale]
    );

    useEffect(() => {
        if (!loginImageUrls.length) return;

        let cancelled = false;
        let remaining = 0;

        loginImageUrls
            .filter((url) => {
                if (preloadedLoginImagesRef.current.has(url)) return false;

                preloadedLoginImagesRef.current.add(url);

                return true;
            })
            .forEach((url) => {
                remaining++;

                const image = new Image();

                image.onload = image.onerror = () => {
                    remaining--;

                    if (!cancelled && remaining <= 0) 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 = readLock();
    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<string, unknown>) => {
        const response = await fetch(url, {
            method: 'POST',
            credentials: 'include',
            headers: {
                'Content-Type': 'application/json',
                Accept: 'application/json',
                'X-Requested-With': 'OctaneLoginView'
            },
            body: JSON.stringify(body)
        });

        let payload: Record<string, unknown> = {};
        try {
            payload = await response.json();
        } catch {}

        return { ok: response.ok, status: response.status, payload };
    }, []);

    const healthUrl = GetConfigurationValue<string>('login.health.endpoint', '');
    const healthMethodRaw = GetConfigurationValue<string>('login.health.method', 'GET');
    const healthMethod = (healthMethodRaw || 'GET').toUpperCase();
    const checkServerReachable = useCallback(async (): Promise<boolean> => {
        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]);

    useEffect(() => {
        let url = interpolate(GetConfigurationValue<string>('login.maintenance.endpoint', ''));
        let source = 'login.maintenance.endpoint';

        if (!url) {
            const apiUrl = interpolate(GetConfigurationValue<string>('api.url', '')).replace(/\/+$/, '');

            try {
                if (/^https?:\/\//i.test(apiUrl)) {
                    url = `${apiUrl}/api/maintenance`;
                    source = `derived from api.url "${apiUrl}"`;
                } else {
                    url = new URL('/api/maintenance', new URL(loginUrl, window.location.href)).toString();
                    source = `derived from login.endpoint "${loginUrl}"`;
                }
            } catch {
                return;
            }
        }

        const controller = new AbortController();
        const reportProbeFailure = (detail: string) => {
            if (!import.meta.env.DEV) return;

            const raw = (key: string) => JSON.stringify(GetOptionalConfigurationValue<unknown>(key, undefined));

            console.warn(
                `[LoginView] maintenance probe ${url} (${source}) failed: ${detail}\n` +
                `  config.urls=${raw('config.urls')}\n` +
                `  login.maintenance.endpoint=${raw('login.maintenance.endpoint')} login.endpoint=${raw('login.endpoint')} api.url=${raw('api.url')}`);
        };

        (async () => {
            try {
                const response = await fetch(url, { credentials: 'omit', signal: controller.signal });

                if (!response.ok) {
                    reportProbeFailure(`HTTP ${response.status}`);

                    return;
                }

                const payload = (await response.json()) as { enabled?: unknown; message?: unknown };

                if (controller.signal.aborted) return;

                setMaintenance({
                    enabled: payload.enabled === true,
                    message: typeof payload.message === 'string' ? payload.message : ''
                });
            } catch (error) {
                if (!controller.signal.aborted) reportProbeFailure(String((error as Error)?.message ?? error));
            }
        })();

        return () => controller.abort();
    }, [loginUrl]);

    const pingLoginServer = useCallback(async () => {
        setLoginPingingServer(true);
        try {
            const ok = await checkServerReachable();
            setLoginServerReachable(ok);
            return ok;
        } finally {
            setLoginPingingServer(false);
        }
    }, [checkServerReachable]);

    const loginAction = useCallback(
        async (_prev: null, formData: FormData): Promise<null> => {
            if (isEntering) return null;

            const nowTs = Date.now();
            if (nowTs - submitTimeRef.current < 1000) return null;
            submitTimeRef.current = nowTs;

            const usernameInput = String(formData.get('username') || '').trim();
            const passwordInput = String(formData.get('password') || '');
            const rememberFlag = formData.get('remember') === 'on';

            const lockSnapshot = readLock();
            if (lockSnapshot.lockedUntil > nowTs) {
                const remaining = Math.ceil((lockSnapshot.lockedUntil - nowTs) / 1000);
                setError(t('nitro.login.error.too_many_attempts', 'Too many attempts. Try again in %seconds%s.', ['seconds'], [String(remaining)]));
                return null;
            }

            if (!usernameInput || !passwordInput) {
                setError(t('nitro.login.error.missing_credentials', 'Please enter both your Habbo name and password.'));
                return null;
            }

            if (turnstileEnabled && !loginTurnstileToken) {
                setError(t('nitro.login.error.turnstile', 'Please complete the security check.'));
                return null;
            }

            setError(null);

            try {
                const { ok, payload } = await postJson(loginUrl, {
                    username: usernameInput,
                    password: passwordInput,
                    remember: rememberFlag,
                    ...(rememberFlag && isRememberCookieMode() ? { rememberCookie: true } : {}),
                    turnstileToken: turnstileEnabled ? loginTurnstileToken : undefined
                });

                const ssoTicket = typeof payload.ssoTicket === 'string' ? payload.ssoTicket : typeof payload.sso === 'string' ? payload.sso : '';

                if (ok && ssoTicket) {
                    clearLock();
                    persistAccessTokenFromPayload(payload, ssoTicket);
                    if (rememberFlag)
                        StoreRememberLoginFromPayload(payload, typeof payload.username === 'string' ? payload.username : usernameInput, isRememberCookieMode());
                    else ClearRememberLogin();
                    onAuthenticated(ssoTicket);
                    return null;
                }

                if (payload.maintenance === true) {
                    const notice =
                        typeof payload.error === 'string'
                            ? payload.error
                            : t('nitro.login.maintenance.notice', 'The hotel is currently undergoing maintenance. Only staff can log in right now.');
                    setMaintenance({ enabled: true, message: notice });
                    setError(notice);
                    resetLoginTurnstile();
                    return null;
                }

                recordFailure();
                const message =
                    typeof payload.error === 'string' ? payload.error : t('nitro.login.error.invalid_credentials', 'Invalid Habbo name or password.');
                setError(message);
                resetLoginTurnstile();
            } catch {
                recordFailure();
                setError(t('nitro.login.error.login_unreachable', 'Unable to reach the login service. Please try again.'));
                resetLoginTurnstile();
            }

            return null;
        },
        [isEntering, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile]
    );

    const [, submitLoginAction, isLoginPending] = useActionState<null, FormData>(loginAction, null);

    const checkEmailUrl = GetConfigurationValue<string>('login.check-email.endpoint', '/api/auth/check-email');
    const checkUsernameUrl = GetConfigurationValue<string>('login.check-username.endpoint', '/api/auth/check-username');
    const interpretAvailability = (ok: boolean, status: number, payload: Record<string, unknown>): { 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 || t('nitro.login.error.email_taken', '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 || t('nitro.login.error.username_taken', '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; templateId: number | null },
            onDialogReset: () => void
        ) => {
            if (turnstileEnabled && !body.turnstileToken) {
                setError(t('nitro.login.error.turnstile', '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,
                    templateId: body.templateId ?? undefined
                });

                if (ok) {
                    clearRegistrationDraft();
                    const friendly = t(
                        'nitro.login.register.success',
                        'Welcome aboard, %username%! Your account is ready — log in below with the password you just chose.',
                        ['username'],
                        [body.username]
                    );
                    setInfo(typeof payload.message === 'string' ? payload.message : friendly);
                    setMode('login');
                    setUsername(body.username);
                    setPassword('');
                    return;
                }

                setError(typeof payload.error === 'string' ? payload.error : t('nitro.login.error.register_failed', 'Unable to create your account.'));
                onDialogReset();
            } catch {
                setError(t('nitro.login.error.register_unreachable', '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(t('nitro.login.error.turnstile', '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 = t(
                        'nitro.login.forgot.success',
                        "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 : t('nitro.login.error.forgot_failed', 'Unable to send a reset email right now.'));
                onDialogReset();
            } catch {
                setError(t('nitro.login.error.forgot_unreachable', 'Unable to reach the password reset service.'));
                onDialogReset();
            } finally {
                setSubmitting(false);
            }
        },
        [turnstileEnabled, forgotUrl, postJson]
    );

    return (
        <NativeTextTree>
            <div className="octane-login-view" style={backgroundColor ? { background: backgroundColor } : undefined}>
                {background ? <img className="login-background login-layer login-layer-img" src={background} alt="" draggable={false} /> : null}
                {sun ? <img className="login-sun login-layer login-layer-img" src={sun} alt="" draggable={false} /> : null}
                {drape ? <img className="login-drape login-layer login-layer-img" src={drape} alt="" draggable={false} /> : null}
                {left ? <img className="login-left login-layer login-layer-img" src={left} alt="" draggable={false} /> : null}
                {rightRepeat ? <div className="login-right-repeat login-layer" style={{ backgroundImage: `url(${rightRepeat})` }} /> : null}
                {right ? <img className="login-right login-layer login-layer-img" src={right} alt="" draggable={false} /> : null}
                <div className="login-image-preloader" aria-hidden="true" data-version={loginImagesVersion}>
                    {loginImageUrls.map((url) => (
                        <img key={url} src={url} decoding="async" loading="eager" alt="" />
                    ))}
                </div>

                {loginWidgetSlots.length > 0 && (
                    <div className="login-widgets">
                        {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 (
                                <LoginWidgetSlot
                                    key={slot.key}
                                    slotKey={String(slot.key)}
                                    type={slot.type}
                                    image={image}
                                    title={title}
                                    description={description}
                                    buttonText={btnText}
                                    buttonLink={btnLink}
                                    dragHint={dragHint}
                                />
                            );
                        })}
                    </div>
                )}

                {newsUrl && <NewsWindow newsUrl={newsUrl} />}

                <div
                    className={`login-language-picker octane-card-shell${languageWindow.dragging ? ' is-dragging' : ''}`}
                    ref={languageWindowRef}
                    style={languageWindow.style}
                >
                    <div className="login-language-header octane-card-header-shell login-drag-handle" title={dragHint} {...languageWindow.handleProps}>
                        <label className="octane-card-title" htmlFor="login-language-select">
                            {localeApplying ? t('nitro.login.language.loading', 'Loading...') : t('nitro.login.language.title', 'Language')}
                        </label>
                    </div>
                    <div className="login-language-content octane-card-content-shell">
                        <div className="login-language-select-wrap">
                            <img src={selectedLocale.flag} alt="" draggable={false} />
                            <Select
                                id="login-language-select"
                                className="form-select"
                                value={selectedLocale.code}
                                disabled={localeApplying}
                                aria-label={t('nitro.login.language.aria', 'Language selection')}
                                onChange={(event) => void changeLocaleSelection(event.target.value)}
                            >
                                {LOGIN_LOCALES.map((locale) => (
                                    <option key={locale.code} value={locale.code}>
                                        {locale.label}
                                    </option>
                                ))}
                            </Select>
                        </div>
                        {localeError.length > 0 && <div className="language-error">{localeError}</div>}
                    </div>
                </div>

                <div className="login-stack">
                    <div
                        className={`octane-login-card octane-card-shell login-auth-card${authWindow.dragging ? ' is-dragging' : ''}`}
                        ref={authWindowRef}
                        style={authWindow.style}
                    >
                        <div className="card-title octane-card-header-shell login-drag-handle" title={dragHint} {...authWindow.handleProps}>
                            <span className="octane-card-title">{t('nitro.login.card.title', "What's your Habbo called?")}</span>
                        </div>
                        <form className="card-body octane-card-content-shell" action={submitLoginAction} autoComplete="on">
                            <div className="field">
                                <label htmlFor="login-username">{t('login.username', 'Name of your Habbo')}</label>
                                <input
                                    id="login-username"
                                    name="username"
                                    autoComplete="username"
                                    type="text"
                                    maxLength={32}
                                    value={username}
                                    onChange={(e) => setUsername(e.target.value)}
                                />
                            </div>
                            <div className="field">
                                <label htmlFor="login-password">{t('generic.password', 'Password')}</label>
                                <input
                                    id="login-password"
                                    name="password"
                                    autoComplete="current-password"
                                    type="password"
                                    maxLength={128}
                                    value={password}
                                    onChange={(e) => setPassword(e.target.value)}
                                />
                            </div>
                            <label className="remember-row">
                                <Checkbox name="remember" checked={rememberMe} onCheckedChange={(nextChecked) => setRememberMe(nextChecked)} />
                                <span>{t('login.remember_me', 'Remember me')}</span>
                            </label>
                            {turnstileEnabled && mode === 'login' && (
                                <TurnstileWidget
                                    siteKey={turnstileSiteKey}
                                    size="compact"
                                    onToken={setLoginTurnstileToken}
                                    onExpire={() => setLoginTurnstileToken('')}
                                    onError={() => setLoginTurnstileToken('')}
                                    resetSignal={loginTurnstileResetSignal}
                                />
                            )}
                            {maintenance?.enabled && (
                                <div className="error-line maintenance-notice" role="status">
                                    <strong>{t('nitro.login.maintenance.title', 'Maintenance mode')}</strong>
                                    <span>
                                        {maintenance.message ||
                                            t('nitro.login.maintenance.notice', 'The hotel is currently undergoing maintenance. Only staff can log in right now.')}
                                    </span>
                                </div>
                            )}
                            {loginServerReachable === false && (
                                <div className="error-line server-offline">
                                    {t('nitro.login.server.offline.short', "The gameserver isn't running right now. Please try again in a moment.")}
                                    <BaseButton type="button" className="retry-link" onClick={pingLoginServer} disabled={loginPingingServer}>
                                        {loginPingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.server.retry', 'Retry')}
                                    </BaseButton>
                                </div>
                            )}
                            {error && <div className="error-line">{error}</div>}
                            {info && <div className="info-line">{info}</div>}
                            <div className="submit-row">
                                <LoginSubmitButton isEntering={isEntering} isLocked={isLocked} loginPingingServer={loginPingingServer} />
                            </div>
                            <BaseButton className="forgot" onClick={() => setMode('forgot')}>
                                {t('login.forgot_password', 'Forgotten your password?')}
                            </BaseButton>
                        </form>
                    </div>

                    <div
                        className={`octane-login-card octane-card-shell login-register-card${registerWindow.dragging ? ' is-dragging' : ''}`}
                        ref={registerWindowRef}
                        style={registerWindow.style}
                    >
                        <div className="card-title octane-card-header-shell login-drag-handle" title={dragHint} {...registerWindow.handleProps}>
                            <span className="octane-card-title">{t('nitro.login.firsttime.title', 'First time here?')}</span>
                        </div>
                        <div className="card-body octane-card-content-shell register-card-body">
                            <span>{t('nitro.login.firsttime.text', "Don't have a Habbo yet?")}</span>
                            <BaseButton type="button" className="register-link" onClick={() => setMode('register')}>
                                {t('nitro.login.firsttime.link', 'You can create one here')}
                            </BaseButton>
                        </div>
                    </div>
                </div>

                {mode === 'register' && (
                    <RegisterDialog
                        onCancel={closeDialog}
                        onSubmit={handleRegisterSubmit}
                        onCheckEmail={checkEmailAvailable}
                        onCheckUsername={checkUsernameAvailable}
                        onCheckServer={checkServerReachable}
                        submitting={submitting}
                        error={error}
                        info={info}
                        turnstileEnabled={turnstileEnabled}
                        turnstileSiteKey={turnstileSiteKey}
                        roomTemplatesUrl={roomTemplatesUrl}
                    />
                )}

                {mode === 'forgot' && (
                    <ForgotDialog
                        onCancel={closeDialog}
                        onSubmit={handleForgotSubmit}
                        submitting={submitting}
                        error={error}
                        info={info}
                        turnstileEnabled={turnstileEnabled}
                        turnstileSiteKey={turnstileSiteKey}
                    />
                )}
            </div>
        </NativeTextTree>
    );
};

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; templateId: number | null },
        onDialogReset: () => void
    ) => Promise<void> | void;
    onCheckEmail: (email: string) => Promise<{ available: boolean; error?: string }>;
    onCheckUsername: (username: string) => Promise<{ available: boolean; error?: string }>;
    onCheckServer: () => Promise<boolean>;
    roomTemplatesUrl: string;
}

type RegisterStep = 'credentials' | 'avatar' | 'room';

interface RoomTemplate {
    templateId: number;
    title: string;
    description: string;
    thumbnail: string;
}

const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

type GenderKey = 'M' | 'F';

const CORE_AVATAR_SET_TYPES = ['hr', 'hd', 'ch', 'lg', 'sh'];
const AVATAR_SET_TYPES = ['hd', 'hr', 'ha', 'he', 'ea', 'fa', 'ch', 'cp', 'cc', 'ca', 'lg', 'sh', 'wa'];

const FALLBACK_DEFAULTS: Record<GenderKey, Record<string, { partId: number; colors: number[] }>> = {
    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] }
    }
};

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;
    sellable?: boolean;
}
interface FigureSetType {
    type: string;
    paletteId: number;
    sets: FigureSet[];
}
interface FigureData {
    palettes: FigurePalette[];
    setTypes: FigureSetType[];
}

interface PartSelection {
    partId: number;
    colors: number[];
}
type FigureSelection = Record<string, PartSelection>;

const buildFigureString = (selection: FigureSelection): string => {
    const seen = new Set<string>();
    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 AVATAR_SET_TYPES) push(setType);
    for (const setType of Object.keys(selection)) push(setType);
    return parts.join('.');
};

const RegisterDialog: FC<RegisterDialogProps> = (props) => {
    const { onCancel, onSubmit, onCheckEmail, onCheckUsername, onCheckServer, submitting, error, info, turnstileEnabled, turnstileSiteKey, roomTemplatesUrl } =
        props;

    const [savedDraft] = useState(loadRegistrationDraft);
    const [step, setStep] = useState<RegisterStep>('credentials');
    const [resumeStep, setResumeStep] = useState<RegisterStep | null>(() => {
        if (!savedDraft || savedDraft.step === 'credentials') return null;
        if (turnstileEnabled && savedDraft.step === 'room') return 'avatar';
        return savedDraft.step;
    });
    const [draftRestored, setDraftRestored] = useState(Boolean(savedDraft));
    const [email, setEmail] = useState(savedDraft?.email ?? '');
    const [password, setPassword] = useState('');
    const [confirm, setConfirm] = useState('');
    const [username, setUsername] = useState(savedDraft?.username ?? '');
    const [gender, setGender] = useState<GenderKey>(savedDraft?.gender ?? 'F');
    const [selection, setSelection] = useState<FigureSelection>(() => ({ ...FALLBACK_DEFAULTS[savedDraft?.gender ?? 'F'], ...savedDraft?.selection }));
    const [localError, setLocalError] = useState<string | null>(null);
    const [prevStep, setPrevStep] = useState<RegisterStep>(step);
    const [turnstileToken, setTurnstileToken] = useState('');
    const [resetSignal, setResetSignal] = useState(0);
    const [roomTemplates, setRoomTemplates] = useState<RoomTemplate[] | null>(null);
    const [roomTemplatesError, setRoomTemplatesError] = useState<string | null>(null);
    const [selectedTemplateId, setSelectedTemplateId] = useState<number | null>(savedDraft?.selectedTemplateId ?? null);

    const persistedDraft = useMemo(
        () => ({
            step: resumeStep ?? step,
            email: email.trim(),
            username: username.trim(),
            gender,
            selection,
            selectedTemplateId
        }),
        [step, resumeStep, email, username, gender, selection, selectedTemplateId]
    );
    const hasMeaningfulDraft = Boolean(
        persistedDraft.email || persistedDraft.username || persistedDraft.step !== 'credentials' || persistedDraft.selectedTemplateId !== null
    );
    const persistDraft = useCallback(() => {
        if (!hasMeaningfulDraft) {
            clearRegistrationDraft();
            return;
        }

        saveRegistrationDraft(persistedDraft);
    }, [hasMeaningfulDraft, persistedDraft]);

    useEffect(() => {
        const timer = window.setTimeout(persistDraft, 150);

        return () => window.clearTimeout(timer);
    }, [persistDraft]);

    useEffect(() => {
        window.addEventListener('pagehide', persistDraft);
        return () => window.removeEventListener('pagehide', persistDraft);
    }, [persistDraft]);

    const closeAndSaveDraft = useCallback(() => {
        persistDraft();
        onCancel();
    }, [persistDraft, onCancel]);

    if (prevStep !== step) {
        setPrevStep(step);
        setLocalError(null);
    }
    const [serverReachable, setServerReachable] = useState<boolean | null>(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);
    }, []);

    const [figureData, setFigureData] = useState<FigureData | null>(null);
    const figureDataUrlRaw = GetConfigurationValue<string>('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<string, Record<GenderKey, number[]>> = {};
        if (!figureData) return result;
        for (const st of figureData.setTypes) {
            if (!AVATAR_SET_TYPES.includes(st.type)) continue;
            const forGender = (g: GenderKey) => [
                ...new Set(st.sets.filter((s) => s.selectable && s.club === 0 && !s.sellable && (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<string, { id: number; hex: string }[]> = {};
        if (!figureData) return result;
        for (const st of figureData.setTypes) {
            if (!AVATAR_SET_TYPES.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 [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<string, unknown>[]) {
                    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, 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];
            if (!AVATAR_SET_TYPES.includes(setType)) continue;
            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 CORE_AVATAR_SET_TYPES) {
            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 credentialsAction = useCallback(
        async (_prev: null, _formData: FormData): Promise<null> => {
            setLocalError(null);

            if (!email.trim() || !password || !confirm) {
                setLocalError(t('nitro.login.error.missing_fields', 'Please fill in every field.'));
                return null;
            }
            if (!EMAIL_REGEX.test(email.trim())) {
                setLocalError(t('nitro.login.error.invalid_email', 'Please enter a valid email address.'));
                return null;
            }
            if (password.length < 8) {
                setLocalError(t('nitro.login.error.password_too_short', 'Your password must be at least 8 characters.'));
                return null;
            }
            if (password !== confirm) {
                setLocalError(t('nitro.login.error.password_mismatch', 'Passwords do not match.'));
                return null;
            }

            const serverOk = await pingServer();
            if (!serverOk) {
                setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.'));
                return null;
            }

            const result = await onCheckEmail(email.trim());
            if (!result.available) {
                setLocalError(result.error || t('nitro.login.error.email_taken', 'This email is already in use.'));
                return null;
            }

            setStep(resumeStep ?? 'avatar');
            setResumeStep(null);
            setDraftRestored(false);
            return null;
        },
        [email, password, confirm, pingServer, onCheckEmail, resumeStep]
    );

    const [, submitCredentialsAction, isCredentialsPending] = useActionState<null, FormData>(credentialsAction, null);

    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 selectWardrobePart = useCallback(
        (setType: string, partId: number) => {
            const availableColors = getColorList(setType);
            setSelection((prev) => ({
                ...Object.fromEntries(Object.entries(prev).filter(([type]) => type !== setType)),
                ...(partId >= 0
                    ? {
                          [setType]: {
                              partId,
                              colors: prev[setType]?.colors?.length ? prev[setType].colors : availableColors.slice(0, 1)
                          }
                      }
                    : {})
            }));
            setHotLookIndex(-1);
        },
        [getColorList]
    );

    const selectWardrobeColor = useCallback((setType: string, colorId: number) => {
        setSelection((prev) => ({
            ...prev,
            [setType]: {
                partId: prev[setType]?.partId ?? -1,
                colors: [colorId]
            }
        }));
        setHotLookIndex(-1);
    }, []);

    const canRandomizeLook = useMemo(() => AVATAR_SET_TYPES.some((setType) => (partOptions[setType]?.[gender]?.length ?? 0) > 1), [partOptions, gender]);

    const randomizeLook = useCallback(() => {
        if (!canRandomizeLook) return;

        const randomItem = <T,>(items: T[]): T | undefined => items[Math.floor(Math.random() * items.length)];
        const nextSelection: FigureSelection = {};

        for (const setType of AVATAR_SET_TYPES) {
            const partIds = getPartList(setType);
            const colorIds = getColorList(setType);
            const isOptional = !CORE_AVATAR_SET_TYPES.includes(setType);

            if (isOptional && Math.random() < 0.55) continue;

            const partId = randomItem(partIds);

            if (partId === undefined) continue;

            nextSelection[setType] = {
                partId,
                colors: colorIds.length ? [randomItem(colorIds) ?? colorIds[0]] : []
            };
        }

        setSelection(nextSelection);
        setHotLookIndex(-1);
    }, [canRandomizeLook, getPartList, getColorList]);

    const figure = buildFigureString(selection);

    const avatarAction = useCallback(
        async (_prev: null, _formData: FormData): Promise<null> => {
            setLocalError(null);

            const trimmed = username.trim();
            if (!trimmed) {
                setLocalError(t('nitro.login.error.missing_username', 'Please choose a Habbo name.'));
                return null;
            }
            if (trimmed.length < 3 || trimmed.length > 16) {
                setLocalError(t('nitro.login.error.username_length', 'Habbo name must be 3–16 characters.'));
                return null;
            }

            if (turnstileEnabled && !turnstileToken) {
                setLocalError(t('nitro.login.error.turnstile', 'Please complete the security check.'));
                return null;
            }

            const serverOk = await pingServer();
            if (!serverOk) {
                setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.'));
                return null;
            }

            const result = await onCheckUsername(trimmed);
            if (!result.available) {
                setLocalError(result.error || t('nitro.login.error.username_taken', 'This Habbo name is already taken.'));
                return null;
            }

            setStep('room');
            return null;
        },
        [username, turnstileEnabled, turnstileToken, pingServer, onCheckUsername]
    );

    const [, submitAvatarAction, isAvatarPending] = useActionState<null, FormData>(avatarAction, null);

    const roomAction = useCallback(
        async (_prev: null, _formData: FormData): Promise<null> => {
            setLocalError(null);

            await onSubmit(
                {
                    username: username.trim(),
                    email: email.trim(),
                    password,
                    figure: buildFigureString(selection),
                    gender,
                    turnstileToken,
                    templateId: selectedTemplateId
                },
                resetWidget
            );

            return null;
        },
        [onSubmit, username, email, password, selection, gender, turnstileToken, selectedTemplateId, resetWidget]
    );

    const [, submitRoomAction, isRoomPending] = useActionState<null, FormData>(roomAction, null);

    useEffect(() => {
        if (step !== 'room' || roomTemplates !== null || !roomTemplatesUrl) return;
        let cancelled = false;
        setRoomTemplatesError(null);
        fetch(roomTemplatesUrl, { credentials: 'include' })
            .then(async (r) => {
                if (!r.ok) throw new Error(`status ${r.status}`);
                return r.json();
            })
            .then((json) => {
                if (cancelled) return;
                const list = Array.isArray((json as { templates?: unknown })?.templates) ? (json as { templates: RoomTemplate[] }).templates : [];
                setRoomTemplates(list);
            })
            .catch(() => {
                if (cancelled) return;
                setRoomTemplates([]);
                setRoomTemplatesError(t('nitro.login.register.room.error', 'Could not load room options. You can still skip this step.'));
            });
        return () => {
            cancelled = true;
        };
    }, [step, roomTemplates, roomTemplatesUrl]);

    const busy = submitting || isCredentialsPending || isAvatarPending || isRoomPending || pingingServer;
    const serverOffline = serverReachable === false;

    return (
        <NativeTextTree>
            <LoginModal
                title={t('nitro.login.register.title', 'Habbo Details')}
                titleId="register-dialog-title"
                closeLabel={t('generic.close', 'Close')}
                dialogClassName={`${step === 'avatar' ? 'dialog-avatar' : ''} ${step === 'room' ? 'dialog-room' : ''}`}
                onClose={closeAndSaveDraft}
            >
                {step === 'credentials' && (
                    <form className="card-body octane-card-content-shell" action={submitCredentialsAction} autoComplete="on">
                        <div className="register-intro octane-card-panel">
                            {t(
                                'nitro.login.register.intro.credentials',
                                "Let's create your account. Enter your email and pick a password — we'll check that email isn't already in use."
                            )}
                        </div>
                        {draftRestored && (
                            <div className="info-line">
                                {t(
                                    'nitro.login.register.draft.restored',
                                    'Your signup draft was restored. For security, re-enter your password to continue where you left off.'
                                )}
                            </div>
                        )}
                        {serverOffline && (
                            <div className="error-line server-offline">
                                {t(
                                    'nitro.login.server.offline.long',
                                    "The gameserver isn't running right now, so new accounts can't be created. Please try again in a moment."
                                )}
                                <BaseButton type="button" className="retry-link" onClick={pingServer} disabled={pingingServer}>
                                    {pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.server.retry', 'Retry')}
                                </BaseButton>
                            </div>
                        )}
                        <div className="field">
                            <label htmlFor="register-email">{t('nitro.login.forgot.email.label', 'Email')}</label>
                            <input
                                id="register-email"
                                name="email"
                                type="email"
                                maxLength={120}
                                autoComplete="email"
                                value={email}
                                onChange={(e) => setEmail(e.target.value)}
                            />
                        </div>
                        <div className="field">
                            <label htmlFor="register-password">{t('generic.password', 'Password')}</label>
                            <input
                                id="register-password"
                                name="password"
                                type="password"
                                maxLength={128}
                                autoComplete="new-password"
                                value={password}
                                onChange={(e) => setPassword(e.target.value)}
                            />
                        </div>
                        <div className="field">
                            <label htmlFor="register-confirm">{t('nitro.login.register.confirm.label', 'Confirm password')}</label>
                            <input
                                id="register-confirm"
                                name="confirm"
                                type="password"
                                maxLength={128}
                                autoComplete="new-password"
                                value={confirm}
                                onChange={(e) => setConfirm(e.target.value)}
                            />
                        </div>
                        {(localError || error) && <div className="error-line">{localError || error}</div>}
                        {info && <div className="info-line">{info}</div>}
                        <div className="step-footer">
                            <span className="step-indicator">1/3</span>
                            <BaseButton type="submit" className="ok-button" disabled={!credentialsValid || busy || serverOffline}>
                                {isCredentialsPending || pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.register.next', 'Next')}
                            </BaseButton>
                        </div>
                    </form>
                )}

                {step === 'avatar' && (
                    <form className="card-body octane-card-content-shell" action={submitAvatarAction} autoComplete="on">
                        <div className="register-intro octane-card-panel">
                            {t(
                                'nitro.login.register.intro.avatar',
                                "Now it's time to make your own Habbo character! To make your own Habbo, please start by choosing your Habbo Name."
                            )}
                        </div>
                        {serverOffline && (
                            <div className="error-line server-offline">
                                {t(
                                    'nitro.login.server.offline.long',
                                    "The gameserver isn't running right now, so new accounts can't be created. Please try again in a moment."
                                )}
                                <BaseButton type="button" className="retry-link" onClick={pingServer} disabled={pingingServer}>
                                    {pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.server.retry', 'Retry')}
                                </BaseButton>
                            </div>
                        )}
                        <div className="field">
                            <input
                                id="register-username"
                                type="text"
                                maxLength={16}
                                autoComplete="username"
                                placeholder={t('nitro.login.register.username.placeholder', 'HabboName')}
                                value={username}
                                onChange={(e) => setUsername(e.target.value)}
                            />
                        </div>

                        <div className="gender-row">
                            <label>
                                <Radio name="register-gender" checked={gender === 'F'} onCheckedChange={() => applyGender('F')} />
                                <span>{t('nitro.login.register.gender.girl', 'Girl')}</span>
                            </label>
                            <label>
                                <Radio name="register-gender" checked={gender === 'M'} onCheckedChange={() => applyGender('M')} />
                                <span>{t('nitro.login.register.gender.boy', 'Boy')}</span>
                            </label>
                        </div>

                        <RegistrationAvatarWardrobe
                            gender={gender}
                            figure={figure}
                            selection={selection}
                            partOptions={partOptions}
                            paletteOptions={paletteOptions}
                            onSelectPart={selectWardrobePart}
                            onSelectColor={selectWardrobeColor}
                        />

                        <div className="hot-looks-row">
                            <Hint content={
                                    hotLooks.length
                                        ? t('nitro.login.register.hotlooks.count', '%count% looks available', ['count'], [String(hotLooks.length)])
                                        : t('nitro.login.register.hotlooks.none', 'No hot looks loaded')
                                }><BaseButton
                                type="button"
                                className="ok-button hot-looks-button"
                                onClick={cycleHotLook}
                                disabled={!hotLooks.length || busy}
                            >
                                {t('nitro.login.register.hotlooks', 'Hot Looks')}
                                {hotLookIndex >= 0 && hotLooks.length ? ` (${hotLookIndex + 1}/${hotLooks.length})` : ''}
                            </BaseButton></Hint>
                            <Hint content={t('nitro.login.register.randomlook.title', 'Generate a random look')}><BaseButton
                                type="button"
                                className="ok-button back-button hot-looks-button random-look-button"
                                onClick={randomizeLook}
                                disabled={!canRandomizeLook || busy}
                            >
                                <FaDice aria-hidden="true" />
                                {t('nitro.login.register.randomlook', 'Random Look')}
                            </BaseButton></Hint>
                        </div>

                        {turnstileEnabled && (
                            <TurnstileWidget
                                siteKey={turnstileSiteKey}
                                size="compact"
                                onToken={setTurnstileToken}
                                onExpire={() => setTurnstileToken('')}
                                onError={() => setTurnstileToken('')}
                                resetSignal={resetSignal}
                            />
                        )}
                        {(localError || error) && <div className="error-line">{localError || error}</div>}
                        {info && <div className="info-line">{info}</div>}

                        <div className="step-footer step-footer-split">
                            <BaseButton type="button" className="ok-button back-button" onClick={() => setStep('credentials')} disabled={busy}>
                                {t('nitro.login.register.back', 'Back')}
                            </BaseButton>
                            <span className="step-indicator">2/3</span>
                            <BaseButton type="submit" className="ok-button" disabled={!username.trim() || busy || serverOffline}>
                                {isAvatarPending
                                    ? t('nitro.login.server.checking', 'Checking…')
                                    : pingingServer
                                      ? t('nitro.login.server.checking', 'Checking…')
                                      : t('nitro.login.register.next', 'Next')}
                            </BaseButton>
                        </div>
                    </form>
                )}

                {step === 'room' && (
                    <form className="card-body octane-card-content-shell" action={submitRoomAction} autoComplete="off">
                        <div className="register-intro octane-card-panel">
                            {t('nitro.login.register.intro.room', 'Last step — pick a starter room, or skip and create your own later.')}
                        </div>

                        {serverOffline && (
                            <div className="error-line server-offline">
                                {t(
                                    'nitro.login.server.offline.long',
                                    "The gameserver isn't running right now, so new accounts can't be created. Please try again in a moment."
                                )}
                                <BaseButton type="button" className="retry-link" onClick={pingServer} disabled={pingingServer}>
                                    {pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.server.retry', 'Retry')}
                                </BaseButton>
                            </div>
                        )}

                        <div className="room-templates-list">
                            <label className={`room-template-option room-template-skip ${selectedTemplateId === null ? 'selected' : ''}`}>
                                <Radio name="register-room-template" checked={selectedTemplateId === null} onCheckedChange={() => setSelectedTemplateId(null)} />
                                <div className="room-template-body">
                                    <div className="room-template-title">{t('nitro.login.register.room.skip.title', "I'm okay — I'll create my own rooms")}</div>
                                    <div className="room-template-description">
                                        {t('nitro.login.register.room.skip.description', 'Skip for now and start with an empty hotel inventory.')}
                                    </div>
                                </div>
                            </label>

                            {roomTemplates === null && <div className="info-line">{t('nitro.login.register.room.loading', 'Loading rooms…')}</div>}

                            {roomTemplates !== null &&
                                roomTemplates.map((template) => (
                                    <label
                                        key={template.templateId}
                                        className={`room-template-option ${selectedTemplateId === template.templateId ? 'selected' : ''}`}
                                    >
                                        <Radio name="register-room-template" checked={selectedTemplateId === template.templateId} onCheckedChange={() => setSelectedTemplateId(template.templateId)} />
                                        {template.thumbnail && (
                                            <img
                                                className="room-template-thumb"
                                                src={template.thumbnail}
                                                alt={template.title}
                                                onError={(e) => {
                                                    (e.currentTarget as HTMLImageElement).style.visibility = 'hidden';
                                                }}
                                            />
                                        )}
                                        <div className="room-template-body">
                                            <div className="room-template-title">{template.title}</div>
                                            {template.description && <div className="room-template-description">{template.description}</div>}
                                        </div>
                                    </label>
                                ))}
                        </div>

                        {roomTemplatesError && <div className="error-line">{roomTemplatesError}</div>}
                        {(localError || error) && <div className="error-line">{localError || error}</div>}
                        {info && <div className="info-line">{info}</div>}

                        <div className="step-footer step-footer-split">
                            <BaseButton type="button" className="ok-button back-button" onClick={() => setStep('avatar')} disabled={busy}>
                                {t('nitro.login.register.back', 'Back')}
                            </BaseButton>
                            <span className="step-indicator">3/3</span>
                            <BaseButton type="submit" className="ok-button" disabled={busy || serverOffline}>
                                {submitting || isRoomPending ? t('nitro.login.register.creating', 'Creating…') : t('nitro.login.register.finish', 'Finish')}
                            </BaseButton>
                        </div>
                    </form>
                )}
            </LoginModal>
        </NativeTextTree>
    );
};

interface ForgotDialogProps extends DialogSharedProps {
    onSubmit: (body: { email: string; turnstileToken: string }, onDialogReset: () => void) => Promise<void> | void;
}

const ForgotSubmitButton: FC = () => {
    const { pending } = useFormStatus();

    return (
        <NativeTextTree>
            <BaseButton type="submit" className="ok-button" disabled={pending}>
                {t('nitro.login.forgot.send', 'Send email')}
            </BaseButton>
        </NativeTextTree>
    );
};

const ForgotDialog: FC<ForgotDialogProps> = (props) => {
    const { onCancel, onSubmit, error, info, turnstileEnabled, turnstileSiteKey } = props;
    const [email, setEmail] = useState('');
    const [localError, setLocalError] = useState<string | null>(null);
    const [turnstileToken, setTurnstileToken] = useState('');
    const [resetSignal, setResetSignal] = useState(0);

    const resetWidget = useCallback(() => {
        setTurnstileToken('');
        setResetSignal((prev) => prev + 1);
    }, []);

    const forgotAction = useCallback(
        async (_prev: null, formData: FormData): Promise<null> => {
            setLocalError(null);

            const emailInput = String(formData.get('email') || '').trim();

            if (!emailInput) {
                setLocalError(t('nitro.login.error.missing_email', 'Please enter your email address.'));
                return null;
            }

            await onSubmit({ email: emailInput, turnstileToken }, resetWidget);
            return null;
        },
        [onSubmit, turnstileToken, resetWidget]
    );

    const [, submitForgotAction] = useActionState<null, FormData>(forgotAction, null);

    return (
        <NativeTextTree>
            <LoginModal
                title={t('nitro.login.forgot.title', 'Reset password')}
                titleId="forgot-dialog-title"
                closeLabel={t('generic.close', 'Close')}
                onClose={onCancel}
            >
                <form className="card-body octane-card-content-shell" action={submitForgotAction} autoComplete="on">
                    <div className="field">
                        <label htmlFor="forgot-email">{t('nitro.login.forgot.email.label', 'Email address')}</label>
                        <input
                            id="forgot-email"
                            name="email"
                            type="email"
                            maxLength={120}
                            autoComplete="email"
                            value={email}
                            onChange={(e) => setEmail(e.target.value)}
                        />
                    </div>
                    {turnstileEnabled && (
                        <TurnstileWidget
                            siteKey={turnstileSiteKey}
                            size="compact"
                            onToken={setTurnstileToken}
                            onExpire={() => setTurnstileToken('')}
                            onError={() => setTurnstileToken('')}
                            resetSignal={resetSignal}
                        />
                    )}
                    {(localError || error) && <div className="error-line">{localError || error}</div>}
                    {info && <div className="info-line">{info}</div>}
                    <div className="submit-row">
                        <ForgotSubmitButton />
                    </div>
                </form>
            </LoginModal>
        </NativeTextTree>
    );
};
15Sujets
1Dépôts
20Membres
2En ligne
Profile
Inscrit
Dernière visite
Messages
0
Votes positifs
0
Votes négatifs
0

Nous utilisons des cookies pour assurer le bon fonctionnement du site et améliorer votre expérience. en savoir plus

Autoriser