🆙 Added Step 3 for UI login registration

This commit is contained in:
duckietm
2026-04-22 16:26:49 +02:00
parent 947b83a0ed
commit ce54d7bc53
2 changed files with 182 additions and 10 deletions
+115 -10
View File
@@ -66,6 +66,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
const right = interpolate(loginImages['right'] || GetConfigurationValue<string>('login_right', ''));
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 turnstileSiteKey = GetConfigurationValue<string>('login.turnstile.sitekey', '');
const rawTurnstileEnabled = GetConfigurationValue<unknown>('login.turnstile.enabled', false);
@@ -316,7 +317,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
}
}, [ 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)
{
@@ -336,6 +337,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
password: body.password,
figure: body.figure,
gender: body.gender,
templateId: body.templateId ?? undefined,
turnstileToken: turnstileEnabled ? body.turnstileToken : undefined
});
@@ -491,6 +493,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
onCheckUsername={ checkUsernameAvailable }
onCheckServer={ checkServerReachable }
imagingUrl={ imagingUrl }
roomTemplatesUrl={ roomTemplatesUrl }
submitting={ submitting }
error={ error }
info={ info }
@@ -524,14 +527,17 @@ interface DialogSharedProps
interface RegisterDialogProps extends DialogSharedProps
{
onSubmit: (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; }, onDialogReset: () => void) => void;
onSubmit: (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; templateId: number | null; }, onDialogReset: () => void) => void;
onCheckEmail: (email: string) => Promise<{ available: boolean; error?: string }>;
onCheckUsername: (username: string) => Promise<{ available: boolean; error?: string }>;
onCheckServer: () => Promise<boolean>;
imagingUrl: string;
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@]+$/;
@@ -642,7 +648,7 @@ const buildPartPreviewUrl = (
const RegisterDialog: FC<RegisterDialogProps> = props =>
{
const { onCancel, onSubmit, onCheckEmail, onCheckUsername, onCheckServer, imagingUrl, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props;
const { onCancel, onSubmit, onCheckEmail, onCheckUsername, onCheckServer, imagingUrl, roomTemplatesUrl, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props;
const [ step, setStep ] = useState<RegisterStep>('credentials');
const [ email, setEmail ] = useState('');
@@ -692,6 +698,10 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
useEffect(() => { setLocalError(null); }, [ step ]);
const [ roomTemplates, setRoomTemplates ] = useState<RoomTemplate[] | null>(null);
const [ roomTemplatesError, setRoomTemplatesError ] = useState<string | null>(null);
const [ selectedTemplateId, setSelectedTemplateId ] = useState<number | null>(null);
const [ figureData, setFigureData ] = useState<FigureData | null>(null);
const figureDataUrlRaw = GetConfigurationValue<string>('avatar.figuredata.url', '');
const figureDataUrl = useMemo(() =>
@@ -712,6 +722,31 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
return () => { cancelled = true; };
}, [ step, figureData, figureDataUrl ]);
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('Could not load room options. You can still skip this step.');
});
return () => { cancelled = true; };
}, [ step, roomTemplates, roomTemplatesUrl ]);
const partOptions = useMemo(() =>
{
const result: Record<string, Record<GenderKey, number[]>> = {};
@@ -974,22 +1009,35 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
setChecking(false);
}
setStep('room');
};
const submitRegistration = (templateId: number | null) =>
{
onSubmit({
username: trimmed,
username: username.trim(),
email: email.trim(),
password,
figure,
gender,
turnstileToken
turnstileToken,
templateId
}, resetWidget);
};
const handleRoomSubmit = (event: FormEvent<HTMLFormElement>) =>
{
event.preventDefault();
setLocalError(null);
submitRegistration(selectedTemplateId);
};
const busy = submitting || checking || 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>Habbo Details</span>
@@ -1027,7 +1075,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 }>
{ checking || pingingServer ? 'Checking…' : 'Next' }
</button>
@@ -1127,9 +1175,66 @@ 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 }>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 ? 'Creating' : (checking || pingingServer) ? 'Checking' : 'Next' }
{ (checking || pingingServer) ? 'Checking' : 'Next' }
</button>
</div>
</form>
}
{ step === 'room' &&
<form className="card-body" onSubmit={ handleRoomSubmit } autoComplete="off">
<div className="register-intro">
Last step — pick a starter room, or skip and create your own later.
</div>
{ serverOffline &&
<div className="error-line server-offline">
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 ? 'Checking' : '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">I'm okay I'll create my own rooms</div>
<div className="room-template-description">Skip for now and start with an empty hotel inventory.</div>
</div>
</label>
{ roomTemplates === null && <div className="info-line">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 }>Back</button>
<span className="step-indicator">3/3</span>
<button type="submit" className="ok-button" disabled={ busy || serverOffline }>
{ submitting ? 'Creating' : 'Finish' }
</button>
</div>
</form>
+67
View File
@@ -454,3 +454,70 @@
font-size: 11px;
}
/* ─── Room template picker (step 3) ─── */
.nitro-login-card .room-templates-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 260px;
overflow-y: auto;
padding-right: 4px;
}
.nitro-login-card .room-template-option {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 10px;
border: 1px solid #b6cfdd;
border-radius: 4px;
background: #eef4f8;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.nitro-login-card .room-template-option:hover {
border-color: #7fa9c3;
}
.nitro-login-card .room-template-option.selected {
border-color: #2e6b92;
background: #d9e8f2;
}
.nitro-login-card .room-template-option input[type="radio"] {
margin: 2px 0 0 0;
flex-shrink: 0;
cursor: pointer;
}
.nitro-login-card .room-template-thumb {
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 3px;
flex-shrink: 0;
}
.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-weight: 700;
font-size: 12px;
color: #0a2e45;
line-height: 1.2;
}
.nitro-login-card .room-template-description {
font-size: 11px;
color: #2a4a5c;
line-height: 1.3;
}