Merge pull request #142 from duckietm/Dev

🆙 Put back Room Templates in UI Login
This commit is contained in:
DuckieTM
2026-05-20 20:54:24 +02:00
committed by GitHub
2 changed files with 222 additions and 14 deletions
+126 -14
View File
@@ -265,6 +265,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
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 turnstileSiteKey = GetConfigurationValue<string>('login.turnstile.sitekey', '');
@@ -601,7 +602,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
}
}, [ checkUsernameUrl, postJson ]);
const handleRegisterSubmit = useCallback(async (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; }, onDialogReset: () => void) =>
const handleRegisterSubmit = useCallback(async (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; templateId: number | null; }, onDialogReset: () => void) =>
{
if(turnstileEnabled && !body.turnstileToken)
{
@@ -621,7 +622,8 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
password: body.password,
figure: body.figure,
gender: body.gender,
turnstileToken: turnstileEnabled ? body.turnstileToken : undefined
turnstileToken: turnstileEnabled ? body.turnstileToken : undefined,
templateId: body.templateId ?? undefined
});
if(ok)
@@ -848,6 +850,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
info={ info }
turnstileEnabled={ turnstileEnabled }
turnstileSiteKey={ turnstileSiteKey }
roomTemplatesUrl={ roomTemplatesUrl }
/> }
{ mode === 'forgot' &&
@@ -876,13 +879,22 @@ interface DialogSharedProps
interface RegisterDialogProps extends DialogSharedProps
{
onSubmit: (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; }, onDialogReset: () => void) => Promise<void> | void;
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';
type RegisterStep = 'credentials' | 'avatar' | 'room';
interface RoomTemplate
{
templateId: number;
title: string;
description: string;
thumbnail: string;
}
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -1116,7 +1128,7 @@ const AvatarPartRow: FC<AvatarPartRowProps> = ({ setType, selection, gender, onP
const RegisterDialog: FC<RegisterDialogProps> = props =>
{
const { onCancel, onSubmit, onCheckEmail, onCheckUsername, onCheckServer, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props;
const { onCancel, onSubmit, onCheckEmail, onCheckUsername, onCheckServer, submitting, error, info, turnstileEnabled, turnstileSiteKey, roomTemplatesUrl } = props;
const [ step, setStep ] = useState<RegisterStep>('credentials');
const [ email, setEmail ] = useState('');
@@ -1129,6 +1141,9 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
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>(null);
if(prevStep !== step)
{
@@ -1463,26 +1478,65 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
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: trimmed,
username: username.trim(),
email: email.trim(),
password,
figure: buildFigureString(selection),
gender,
turnstileToken
turnstileToken,
templateId: selectedTemplateId
}, resetWidget);
return null;
}, [ username, turnstileEnabled, turnstileToken, pingServer, onCheckUsername, onSubmit, email, password, selection, gender, resetWidget ]);
}, [ onSubmit, username, email, password, selection, gender, turnstileToken, selectedTemplateId, resetWidget ]);
const [ , submitAvatarAction, isAvatarPending ] = useActionState<null, FormData>(avatarAction, null);
const [ , submitRoomAction, isRoomPending ] = useActionState<null, FormData>(roomAction, null);
const busy = submitting || isCredentialsPending || isAvatarPending || pingingServer;
useEffect(() =>
{
if(step !== 'room' || roomTemplates !== null || !roomTemplatesUrl) return;
let cancelled = false;
setRoomTemplatesError(null);
fetch(roomTemplatesUrl, { credentials: 'include' })
.then(async r =>
{
if(!r.ok) throw new Error(`status ${ r.status }`);
return r.json();
})
.then(json =>
{
if(cancelled) return;
const list = Array.isArray((json as { templates?: unknown })?.templates)
? (json as { templates: RoomTemplate[] }).templates
: [];
setRoomTemplates(list);
})
.catch(() =>
{
if(cancelled) return;
setRoomTemplates([]);
setRoomTemplatesError(t('nitro.login.register.room.error', 'Could not load room options. You can still skip this step.'));
});
return () => { cancelled = true; };
}, [ step, roomTemplates, roomTemplatesUrl ]);
const busy = submitting || isCredentialsPending || isAvatarPending || isRoomPending || pingingServer;
const serverOffline = serverReachable === false;
return (
<div className="nitro-login-modal">
<div className={ `dialog ${ step === 'avatar' ? 'dialog-avatar' : '' }` }>
<div className={ `dialog ${ step === 'avatar' ? 'dialog-avatar' : '' } ${ step === 'room' ? 'dialog-room' : '' }` }>
<div className="nitro-login-card">
<div className="card-title">
<span>{ t('nitro.login.register.title', 'Habbo Details') }</span>
@@ -1520,7 +1574,7 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
{ (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/2</span>
<span className="step-indicator">1/3</span>
<button type="submit" className="ok-button" disabled={ !credentialsValid || busy || serverOffline }>
{ isCredentialsPending || pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.register.next', 'Next') }
</button>
@@ -1622,9 +1676,67 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
<div className="step-footer step-footer-split">
<button type="button" className="ok-button back-button" onClick={ () => setStep('credentials') } disabled={ busy }>{ t('nitro.login.register.back', 'Back') }</button>
<span className="step-indicator">2/2</span>
<span className="step-indicator">2/3</span>
<button type="submit" className="ok-button" disabled={ !username.trim() || busy || serverOffline }>
{ (submitting || isAvatarPending) ? t('nitro.login.register.creating', 'Creating…') : pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.register.next', 'Next') }
{ isAvatarPending ? t('nitro.login.server.checking', 'Checking…') : pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.register.next', 'Next') }
</button>
</div>
</form>
}
{ step === 'room' &&
<form className="card-body" action={ submitRoomAction } autoComplete="off">
<div className="register-intro">
{ 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.') }
<button type="button" className="retry-link" onClick={ pingServer } disabled={ pingingServer }>
{ pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.server.retry', 'Retry') }
</button>
</div>
}
<div className="room-templates-list">
<label className={ `room-template-option room-template-skip ${ selectedTemplateId === null ? 'selected' : '' }` }>
<input type="radio" name="register-room-template" checked={ selectedTemplateId === null }
onChange={ () => 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' : '' }` }>
<input type="radio" name="register-room-template" checked={ selectedTemplateId === template.templateId }
onChange={ () => 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">
<button type="button" className="ok-button back-button" onClick={ () => setStep('avatar') } disabled={ busy }>{ t('nitro.login.register.back', 'Back') }</button>
<span className="step-indicator">3/3</span>
<button type="submit" className="ok-button" disabled={ busy || serverOffline }>
{ (submitting || isRoomPending) ? t('nitro.login.register.creating', 'Creating…') : t('nitro.login.register.finish', 'Finish') }
</button>
</div>
</form>
+96
View File
@@ -1174,3 +1174,99 @@
animation-duration: 0.4s !important;
}
}
/* ─── Register dialog · room template step ─── */
.nitro-login-modal .dialog.dialog-room {
width: 400px;
}
.nitro-login-card .room-templates-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 280px;
overflow-y: auto;
overflow-x: hidden;
padding-right: 4px;
margin-top: 2px;
scrollbar-width: thin;
}
.nitro-login-card .room-template-option {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
background: linear-gradient(180deg, #f4f9fc 0%, #dbe9f1 100%);
border: 1px solid #a4c0d2;
border-radius: 6px;
cursor: pointer;
transition: border-color 0.12s ease, box-shadow 0.12s ease, background 0.12s ease;
color: #0a2e45;
user-select: none;
position: relative;
}
.nitro-login-card .room-template-option:hover {
border-color: #4a8ec0;
box-shadow: 0 0 0 1px rgba(74, 142, 192, 0.45);
}
.nitro-login-card .room-template-option.selected {
border-color: #2a78b8;
background: linear-gradient(180deg, #eaf4fb 0%, #c5dcec 100%);
box-shadow: 0 0 0 2px rgba(42, 120, 184, 0.55), 0 1px 2px rgba(0, 0, 0, 0.08);
}
.nitro-login-card .room-template-option input[type="radio"] {
flex-shrink: 0;
margin: 0;
accent-color: #2a78b8;
cursor: pointer;
}
.nitro-login-card .room-template-thumb {
width: 64px;
height: 48px;
object-fit: cover;
border-radius: 4px;
border: 1px solid #a4c0d2;
background: #fff;
flex-shrink: 0;
image-rendering: pixelated;
}
.nitro-login-card .room-template-body {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.nitro-login-card .room-template-title {
font-size: 12px;
font-weight: 700;
color: #0a2e45;
line-height: 1.2;
text-transform: capitalize;
}
.nitro-login-card .room-template-description {
font-size: 11px;
line-height: 1.35;
color: #486175;
word-break: break-word;
}
/* The "Skip" option sits at the top with a softer dashed border so it
visually distinguishes itself from the actual templates. */
.nitro-login-card .room-template-option.room-template-skip {
background: linear-gradient(180deg, #fbfcfd 0%, #e7eef4 100%);
border-style: dashed;
}
.nitro-login-card .room-template-option.room-template-skip.selected {
border-style: solid;
}