Merge branch 'Dev' into merge-duckie-main-2026-05-06
@@ -1,6 +1,6 @@
|
||||
import { GetAssetManager, GetAvatarRenderManager, GetCommunication, GetConfiguration, GetLocalizationManager, GetRoomEngine, GetRoomSessionManager, GetSessionDataManager, GetSoundManager, GetStage, GetTexturePool, GetTicker, HabboWebTools, LegacyExternalInterface, LoadGameUrlEvent, NitroEventType, NitroLogger, NitroVersion, PrepareRenderer } from '@nitrots/nitro-renderer';
|
||||
import { FC, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ClearRememberLogin, GetRememberLogin, GetUIVersion, StoreRememberLoginFromPayload, persistAccessTokenFromPayload } from './api';
|
||||
import { FC, useCallback, useEffect, useEffectEvent, useRef, useState } from 'react';
|
||||
import { ClearRememberLogin, GetRememberLogin, GetUIVersion, SetRememberLogin, StoreRememberLoginFromPayload, persistAccessTokenFromPayload } from './api';
|
||||
import { Base } from './common';
|
||||
import { LoadingView } from './components/loading/LoadingView';
|
||||
import { LoginView } from './components/login/LoginView';
|
||||
@@ -36,7 +36,8 @@ const preloadUrl = async (url: string): Promise<void> =>
|
||||
const response = await fetch(url, { cache: 'force-cache' });
|
||||
await response.arrayBuffer();
|
||||
}
|
||||
catch {}
|
||||
catch
|
||||
{}
|
||||
};
|
||||
|
||||
const preloadImage = (url: string): void =>
|
||||
@@ -49,7 +50,8 @@ const preloadImage = (url: string): void =>
|
||||
image.decoding = 'async';
|
||||
image.src = url;
|
||||
}
|
||||
catch {}
|
||||
catch
|
||||
{}
|
||||
};
|
||||
|
||||
const asStringArray = (value: unknown): string[] =>
|
||||
@@ -70,20 +72,99 @@ export const App: FC<{}> = props =>
|
||||
const [ showLogin, setShowLogin ] = useState(false);
|
||||
const [ isEnteringHotel, setIsEnteringHotel ] = useState(() => !!window.NitroConfig?.['sso.ticket'] || hasRememberLogin());
|
||||
const [ prepareTrigger, setPrepareTrigger ] = useState(0);
|
||||
const [ loadingProgress, setLoadingProgress ] = useState(0);
|
||||
const [ loadingTask, setLoadingTask ] = useState('');
|
||||
const taskLabel = useCallback((key: string, fallback: string): string =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const locManager = GetLocalizationManager();
|
||||
if(locManager && typeof locManager.getValue === 'function')
|
||||
{
|
||||
const fromLoc = locManager.getValue(key, false);
|
||||
|
||||
if(typeof fromLoc === 'string' && fromLoc.length && fromLoc !== key) return fromLoc;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{ }
|
||||
|
||||
try
|
||||
{
|
||||
const fromConfig = GetConfiguration().getValue<string>(key, '');
|
||||
if(typeof fromConfig === 'string' && fromConfig.length) return fromConfig;
|
||||
}
|
||||
catch
|
||||
{ }
|
||||
|
||||
return fallback;
|
||||
}, []);
|
||||
const bumpProgress = useCallback((value: number, task?: string) =>
|
||||
{
|
||||
setLoadingProgress(prev => (value > prev ? value : prev));
|
||||
if(task !== undefined) setLoadingTask(task);
|
||||
}, []);
|
||||
const warmupPromiseRef = useRef<Promise<void>>(null);
|
||||
const rendererPromiseRef = useRef<Promise<any>>(null);
|
||||
const gameInitPromiseRef = useRef<Promise<void> | null>(null);
|
||||
const bootstrapDoneRef = useRef(false);
|
||||
const lastPrepareTriggerRef = useRef<number | null>(null);
|
||||
const tickersStartedRef = useRef(false);
|
||||
const heartbeatIntervalRef = useRef<number>(null);
|
||||
const rememberRotateIntervalRef = useRef<number>(null);
|
||||
const isReadyRef = useRef(false);
|
||||
const reconnectInProgressRef = useRef(false);
|
||||
|
||||
const clearStoredCredentials = useCallback(() =>
|
||||
{
|
||||
ClearRememberLogin();
|
||||
try { delete (window as any).NitroConfig?.['sso.ticket']; } catch {}
|
||||
try { GetConfiguration().setValue('sso.ticket', ''); } catch {}
|
||||
try
|
||||
{
|
||||
const url = new URL(window.location.href);
|
||||
|
||||
if(url.searchParams.has('sso'))
|
||||
{
|
||||
url.searchParams.delete('sso');
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
}
|
||||
}
|
||||
catch {}
|
||||
}, []);
|
||||
|
||||
const showSessionExpired = useCallback(() =>
|
||||
{
|
||||
console.warn('[App] showSessionExpired — diagnostic shown (mid-game close)');
|
||||
clearStoredCredentials();
|
||||
|
||||
const baseUrl = window.location.origin + '/';
|
||||
setHomeUrl(baseUrl);
|
||||
setErrorMessage('Your session has expired.\nPlease log in again to enter the hotel.');
|
||||
setIsReady(false);
|
||||
setShowLogin(false);
|
||||
setIsEnteringHotel(false);
|
||||
}, []);
|
||||
}, [ clearStoredCredentials ]);
|
||||
|
||||
const fallbackToLogin = useCallback(() =>
|
||||
{
|
||||
const rawLoginEnabled = GetConfiguration().getValue<unknown>('login.screen.enabled', false);
|
||||
const loginScreenEnabled = rawLoginEnabled === true || rawLoginEnabled === 'true' || rawLoginEnabled === 1;
|
||||
|
||||
if(!loginScreenEnabled)
|
||||
{
|
||||
console.warn('[App] fallbackToLogin — login.screen.enabled=false, redirecting to home instead');
|
||||
showSessionExpired();
|
||||
return;
|
||||
}
|
||||
console.warn('[App] fallbackToLogin — surfacing login form, credentials cleared');
|
||||
clearStoredCredentials();
|
||||
setHomeUrl('');
|
||||
setErrorMessage('');
|
||||
setIsReady(false);
|
||||
setShowLogin(true);
|
||||
setIsEnteringHotel(false);
|
||||
}, [ clearStoredCredentials, showSessionExpired ]);
|
||||
|
||||
const applySsoTicket = useCallback((ssoTicket: string) =>
|
||||
{
|
||||
@@ -105,10 +186,18 @@ export const App: FC<{}> = props =>
|
||||
{
|
||||
const remembered = GetRememberLogin();
|
||||
|
||||
if(!remembered) return '';
|
||||
if(!remembered.token?.length && remembered.ssoTicket?.length) return remembered.ssoTicket;
|
||||
console.warn('[App] tryRememberLogin start', {
|
||||
hasRemembered: !!remembered,
|
||||
hasToken: !!remembered?.token?.length,
|
||||
hasStoredSso: !!remembered?.ssoTicket?.length
|
||||
});
|
||||
|
||||
let allowSsoFallback = true;
|
||||
if(!remembered?.token?.length)
|
||||
{
|
||||
if(remembered) ClearRememberLogin();
|
||||
console.warn('[App] tryRememberLogin → no token, returning empty');
|
||||
return '';
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -126,30 +215,35 @@ export const App: FC<{}> = props =>
|
||||
});
|
||||
|
||||
let payload: Record<string, unknown> = {};
|
||||
try { payload = await response.json(); }
|
||||
catch {}
|
||||
try
|
||||
{
|
||||
payload = await response.json();
|
||||
}
|
||||
catch
|
||||
{}
|
||||
|
||||
const ssoTicket = typeof payload.ssoTicket === 'string' ? payload.ssoTicket : (typeof payload.sso === 'string' ? payload.sso : '');
|
||||
|
||||
console.warn('[App] tryRememberLogin → remember endpoint replied', {
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
gotSsoTicket: !!ssoTicket
|
||||
});
|
||||
|
||||
if(response.ok && ssoTicket)
|
||||
{
|
||||
persistAccessTokenFromPayload(payload);
|
||||
StoreRememberLoginFromPayload(payload, typeof payload.username === 'string' ? payload.username : remembered.username, ssoTicket);
|
||||
return ssoTicket;
|
||||
}
|
||||
|
||||
if(response.status === 400 || response.status === 401 || response.status === 403)
|
||||
{
|
||||
allowSsoFallback = false;
|
||||
ClearRememberLogin();
|
||||
}
|
||||
}
|
||||
catch(error)
|
||||
{
|
||||
NitroLogger.error('[LoginScreen] Remember login failed', error);
|
||||
console.warn('[App] tryRememberLogin → fetch threw', error);
|
||||
}
|
||||
|
||||
if(allowSsoFallback && remembered.ssoTicket?.length) return remembered.ssoTicket;
|
||||
ClearRememberLogin();
|
||||
console.warn('[App] tryRememberLogin → cleared remember, returning empty');
|
||||
|
||||
return '';
|
||||
}, []);
|
||||
@@ -176,8 +270,12 @@ export const App: FC<{}> = props =>
|
||||
});
|
||||
|
||||
let payload: Record<string, unknown> = {};
|
||||
try { payload = await response.json(); }
|
||||
catch {}
|
||||
try
|
||||
{
|
||||
payload = await response.json();
|
||||
}
|
||||
catch
|
||||
{}
|
||||
|
||||
if(response.ok)
|
||||
{
|
||||
@@ -194,8 +292,28 @@ export const App: FC<{}> = props =>
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Listen for socket closed events (code 1000 "Bye" - server rejected SSO)
|
||||
useNitroEvent(NitroEventType.SOCKET_CLOSED, showSessionExpired);
|
||||
useEffect(() => { isReadyRef.current = isReady; }, [ isReady ]);
|
||||
useNitroEvent(NitroEventType.SOCKET_RECONNECTING, () => { reconnectInProgressRef.current = true; });
|
||||
useNitroEvent(NitroEventType.SOCKET_REAUTHENTICATED, () => { reconnectInProgressRef.current = false; });
|
||||
|
||||
useNitroEvent(NitroEventType.SOCKET_CLOSED, () =>
|
||||
{
|
||||
console.warn('[App] SOCKET_CLOSED fired', {
|
||||
isReady: isReadyRef.current,
|
||||
reconnectInProgress: reconnectInProgressRef.current
|
||||
});
|
||||
|
||||
if(!isReadyRef.current)
|
||||
{
|
||||
console.warn('[App] Socket closed before authentication completed — falling back to login');
|
||||
fallbackToLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
if(reconnectInProgressRef.current) return;
|
||||
|
||||
showSessionExpired();
|
||||
});
|
||||
|
||||
useMessageEvent<LoadGameUrlEvent>(LoadGameUrlEvent, event =>
|
||||
{
|
||||
@@ -238,6 +356,7 @@ export const App: FC<{}> = props =>
|
||||
warmupPromiseRef.current = (async () =>
|
||||
{
|
||||
await GetConfiguration().init();
|
||||
bumpProgress(25, taskLabel('loader.waiting', 'Loading content...'));
|
||||
|
||||
GetTicker().maxFPS = GetConfiguration().getValue<number>('system.fps.max', 24);
|
||||
NitroLogger.LOG_DEBUG = GetConfiguration().getValue<boolean>('system.log.debug', true);
|
||||
@@ -274,18 +393,25 @@ export const App: FC<{}> = props =>
|
||||
loginImageUrls.forEach(preloadImage);
|
||||
gamedataUrls.forEach(url => preloadUrl(url));
|
||||
|
||||
await Promise.all(
|
||||
[
|
||||
GetAssetManager().downloadAssets(assetUrls),
|
||||
GetLocalizationManager().init(),
|
||||
GetAvatarRenderManager().init(),
|
||||
GetSoundManager().init()
|
||||
]
|
||||
);
|
||||
const warmupTasks: { promise: Promise<any>; label: string }[] = [
|
||||
{ promise: GetAssetManager().downloadAssets(assetUrls), label: taskLabel('loading.task.assets', 'Loading game assets...') },
|
||||
{ promise: GetLocalizationManager().init(), label: taskLabel('loading.task.localization', 'Loading translations...') },
|
||||
{ promise: GetAvatarRenderManager().init(), label: taskLabel('loading.task.avatar', 'Loading wardrobe...') },
|
||||
{ promise: GetSoundManager().init(), label: taskLabel('loading.task.sounds', 'Loading sounds...') }
|
||||
];
|
||||
let warmupDone = 0;
|
||||
const warmupStart = 25;
|
||||
const warmupSpan = 45;
|
||||
await Promise.all(warmupTasks.map(t => t.promise.then(value =>
|
||||
{
|
||||
warmupDone++;
|
||||
bumpProgress(warmupStart + Math.round((warmupSpan * warmupDone) / warmupTasks.length), t.label);
|
||||
return value;
|
||||
})));
|
||||
})();
|
||||
|
||||
return warmupPromiseRef.current;
|
||||
}, [ startRenderer ]);
|
||||
}, [ startRenderer, bumpProgress, taskLabel ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
@@ -306,10 +432,25 @@ export const App: FC<{}> = props =>
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onSessionExpired = useEffectEvent(() => showSessionExpired());
|
||||
const onInitFailure = useEffectEvent(() => fallbackToLogin());
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const prepare = async (width: number, height: number) =>
|
||||
{
|
||||
console.warn('[App] prepare() start', {
|
||||
hasNitroConfig: !!window.NitroConfig,
|
||||
ssoTicketInConfig: !!window.NitroConfig?.['sso.ticket'],
|
||||
hasRememberLocal: !!GetRememberLogin(),
|
||||
hasUrlSso: !!new URLSearchParams(window.location.search).get('sso')
|
||||
});
|
||||
|
||||
const bootLabel = taskLabel('loader', 'Booting...');
|
||||
setLoadingProgress(0);
|
||||
setLoadingTask(bootLabel);
|
||||
bumpProgress(5, bootLabel);
|
||||
|
||||
try
|
||||
{
|
||||
if(!window.NitroConfig) throw new Error('NitroConfig is not defined!');
|
||||
@@ -317,17 +458,49 @@ export const App: FC<{}> = props =>
|
||||
let ssoTicket = window.NitroConfig['sso.ticket'];
|
||||
if(ssoTicket) GetConfiguration().setValue('sso.ticket', ssoTicket);
|
||||
|
||||
try
|
||||
{
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const tokenParam = urlParams.get('token');
|
||||
const tokenExpParam = urlParams.get('token_exp');
|
||||
if(tokenParam && !GetRememberLogin())
|
||||
{
|
||||
const parsedExpiry = Number(tokenExpParam || 0);
|
||||
const expiresAt = (Number.isFinite(parsedExpiry) && parsedExpiry > 0)
|
||||
? parsedExpiry
|
||||
: Math.floor(Date.now() / 1000) + (30 * 24 * 60 * 60);
|
||||
SetRememberLogin({ token: tokenParam, expiresAt });
|
||||
}
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
console.warn('[App] failed to persist remember token from URL', e);
|
||||
}
|
||||
|
||||
bumpProgress(10, taskLabel('loading.task.session', 'Verifying session...'));
|
||||
|
||||
if(!ssoTicket || ssoTicket === '')
|
||||
{
|
||||
// Configuration is loaded lazily — fetch it up-front so the login
|
||||
// screen toggle and Turnstile keys are available before we decide.
|
||||
let configInitError: unknown = null;
|
||||
try { await GetConfiguration().init(); }
|
||||
catch(e) { configInitError = e; }
|
||||
try
|
||||
{
|
||||
await GetConfiguration().init();
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
configInitError = e;
|
||||
}
|
||||
|
||||
const rawLoginEnabled = GetConfiguration().getValue<unknown>('login.screen.enabled', false);
|
||||
const loginScreenEnabled = rawLoginEnabled === true || rawLoginEnabled === 'true' || rawLoginEnabled === 1;
|
||||
|
||||
console.warn('[App] no SSO path — login gate', {
|
||||
configInitError: configInitError ? String((configInitError as Error)?.message ?? configInitError) : null,
|
||||
rawLoginEnabled,
|
||||
rawLoginEnabledType: typeof rawLoginEnabled,
|
||||
loginScreenEnabled
|
||||
});
|
||||
|
||||
if(configInitError)
|
||||
{
|
||||
NitroLogger.error('[LoginScreen] Failed to load renderer-config.json — cannot resolve login.screen.enabled', configInitError);
|
||||
@@ -363,22 +536,40 @@ export const App: FC<{}> = props =>
|
||||
return;
|
||||
}
|
||||
|
||||
showSessionExpired();
|
||||
onSessionExpired();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const renderer = await startRenderer(width, height);
|
||||
bumpProgress(20, taskLabel('loading.task.renderer', 'Initializing renderer...'));
|
||||
|
||||
await startWarmup(width, height);
|
||||
await GetSessionDataManager().init();
|
||||
await GetRoomSessionManager().init();
|
||||
await GetRoomEngine().init();
|
||||
await GetCommunication().init();
|
||||
bumpProgress(70, taskLabel('loading.task.startsession', 'Starting session...'));
|
||||
|
||||
if(LegacyExternalInterface.available) LegacyExternalInterface.call('legacyTrack', 'authentication', 'authok', []);
|
||||
if(!gameInitPromiseRef.current)
|
||||
{
|
||||
gameInitPromiseRef.current = (async () =>
|
||||
{
|
||||
await GetSessionDataManager().init();
|
||||
bumpProgress(78, taskLabel('loading.task.userdata', 'Loading user data...'));
|
||||
await GetRoomSessionManager().init();
|
||||
bumpProgress(85, taskLabel('loading.task.rooms', 'Loading rooms...'));
|
||||
await GetRoomEngine().init();
|
||||
bumpProgress(92, taskLabel('loading.task.engine', 'Loading graphics engine...'));
|
||||
await GetCommunication().init();
|
||||
bumpProgress(98, taskLabel('generic.reconnecting', 'Connecting to server...'));
|
||||
})();
|
||||
}
|
||||
|
||||
HabboWebTools.sendHeartBeat();
|
||||
await gameInitPromiseRef.current;
|
||||
|
||||
if(!bootstrapDoneRef.current)
|
||||
{
|
||||
bootstrapDoneRef.current = true;
|
||||
if(LegacyExternalInterface.available) LegacyExternalInterface.call('legacyTrack', 'authentication', 'authok', []);
|
||||
HabboWebTools.sendHeartBeat();
|
||||
}
|
||||
|
||||
if(heartbeatIntervalRef.current !== null) window.clearInterval(heartbeatIntervalRef.current);
|
||||
heartbeatIntervalRef.current = window.setInterval(() => HabboWebTools.sendHeartBeat(), 10000);
|
||||
@@ -396,18 +587,21 @@ export const App: FC<{}> = props =>
|
||||
GetTicker().add(ticker => GetTexturePool().run());
|
||||
}
|
||||
|
||||
bumpProgress(100, taskLabel('onboarding.button.ready', 'Ready!'));
|
||||
setIsReady(true);
|
||||
setShowLogin(false);
|
||||
setIsEnteringHotel(false);
|
||||
}
|
||||
catch(err)
|
||||
{
|
||||
NitroLogger.error(err);
|
||||
setIsEnteringHotel(false);
|
||||
showSessionExpired();
|
||||
NitroLogger.error('[App] Initialization failed — falling back to login', err);
|
||||
onInitFailure();
|
||||
}
|
||||
};
|
||||
|
||||
if(lastPrepareTriggerRef.current === prepareTrigger) return;
|
||||
lastPrepareTriggerRef.current = prepareTrigger;
|
||||
|
||||
const { width, height } = getViewportDimensions();
|
||||
|
||||
prepare(width, height);
|
||||
@@ -417,15 +611,15 @@ export const App: FC<{}> = props =>
|
||||
if(heartbeatIntervalRef.current !== null) window.clearInterval(heartbeatIntervalRef.current);
|
||||
if(rememberRotateIntervalRef.current !== null) window.clearInterval(rememberRotateIntervalRef.current);
|
||||
};
|
||||
}, [ prepareTrigger, startWarmup, startRenderer, tryRememberLogin, applySsoTicket, rotateRememberLogin ]);
|
||||
}, [ prepareTrigger, startWarmup, startRenderer, tryRememberLogin, applySsoTicket, rotateRememberLogin, bumpProgress, taskLabel ]);
|
||||
|
||||
return (
|
||||
<Base fit overflow="hidden" className={ `nitro-app-root ${ !(window.devicePixelRatio % 1) ? 'image-rendering-pixelated' : '' }` }>
|
||||
{ !isReady && !showLogin &&
|
||||
<LoadingView isError={ errorMessage.length > 0 } message={ errorMessage } homeUrl={ homeUrl } /> }
|
||||
<LoadingView isError={ errorMessage.length > 0 } message={ errorMessage } homeUrl={ homeUrl } progress={ loadingProgress } currentTask={ loadingTask } /> }
|
||||
{ !isReady && showLogin && <LoginView onAuthenticated={ handleAuthenticated } isEntering={ isEnteringHotel } /> }
|
||||
{ isReady && <MainView /> }
|
||||
<ReconnectView />
|
||||
{ isReady && <ReconnectView /> }
|
||||
<Base id="draggable-windows-container" />
|
||||
</Base>
|
||||
);
|
||||
|
||||
@@ -17,13 +17,20 @@ export const setAccessToken = (token: string | null | undefined, expiresAt?: num
|
||||
window.localStorage.removeItem(EXPIRES_KEY);
|
||||
}
|
||||
}
|
||||
catch {}
|
||||
catch
|
||||
{}
|
||||
};
|
||||
|
||||
export const getAccessToken = (): string =>
|
||||
{
|
||||
try { return window.localStorage.getItem(STORAGE_KEY) ?? ''; }
|
||||
catch { return ''; }
|
||||
try
|
||||
{
|
||||
return window.localStorage.getItem(STORAGE_KEY) ?? '';
|
||||
}
|
||||
catch
|
||||
{
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const getAccessTokenExpiresAt = (): number =>
|
||||
@@ -35,7 +42,10 @@ export const getAccessTokenExpiresAt = (): number =>
|
||||
const value = parseInt(raw, 10);
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
catch { return 0; }
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
export const clearAccessToken = (): void =>
|
||||
|
||||
@@ -87,7 +87,7 @@ export class AvatarEditorThumbnailsHelper
|
||||
AvatarFigurePartType.PET,
|
||||
'ptl',
|
||||
'ptr',
|
||||
AvatarFigurePartType.MISC,
|
||||
AvatarFigurePartType.MISC,
|
||||
'mcl',
|
||||
'mcr',
|
||||
];
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { dedupeBadges } from './dedupeBadges';
|
||||
|
||||
describe('dedupeBadges', () =>
|
||||
{
|
||||
it('returns an empty array for an empty input', () =>
|
||||
{
|
||||
expect(dedupeBadges([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves unique badges in slot order', () =>
|
||||
{
|
||||
expect(dedupeBadges([ 'a', 'b', 'c' ])).toEqual([ 'a', 'b', 'c' ]);
|
||||
});
|
||||
|
||||
it('replaces duplicate slots with empty strings to preserve slot indices', () =>
|
||||
{
|
||||
expect(dedupeBadges([ 'a', 'b', 'a', 'c' ])).toEqual([ 'a', 'b', '', 'c' ]);
|
||||
});
|
||||
|
||||
it('normalizes falsy entries (null, undefined, "") to empty string', () =>
|
||||
{
|
||||
// server sometimes returns null/undefined for unused slots
|
||||
const input = [ 'a', null as unknown as string, '', undefined as unknown as string, 'b' ];
|
||||
|
||||
expect(dedupeBadges(input)).toEqual([ 'a', '', '', '', 'b' ]);
|
||||
});
|
||||
|
||||
it('only keeps the FIRST occurrence of each unique code', () =>
|
||||
{
|
||||
expect(dedupeBadges([ 'a', 'a', 'a' ])).toEqual([ 'a', '', '' ]);
|
||||
});
|
||||
|
||||
it('is order-sensitive: identical multisets but different orderings yield different outputs', () =>
|
||||
{
|
||||
expect(dedupeBadges([ 'a', 'b', 'a' ])).toEqual([ 'a', 'b', '' ]);
|
||||
expect(dedupeBadges([ 'b', 'a', 'a' ])).toEqual([ 'b', 'a', '' ]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Strips duplicate badge codes from a server-supplied badge array,
|
||||
* preserving slot indices: a duplicate is replaced by an empty string
|
||||
* rather than shifted out, so badge[i] still corresponds to slot i.
|
||||
*
|
||||
* Empty / falsy entries are normalized to '' (some servers emit null
|
||||
* inside the array for unused slots).
|
||||
*/
|
||||
export const dedupeBadges = (badges: ReadonlyArray<string>): string[] =>
|
||||
{
|
||||
const seen = new Set<string>();
|
||||
|
||||
return badges.map(code =>
|
||||
{
|
||||
if(!code || seen.has(code)) return '';
|
||||
|
||||
seen.add(code);
|
||||
|
||||
return code;
|
||||
});
|
||||
};
|
||||
@@ -3,5 +3,6 @@ export * from './AvatarEditorColorSorter';
|
||||
export * from './AvatarEditorPartSorter';
|
||||
export * from './AvatarEditorThumbnailsHelper';
|
||||
export * from './BuildPurchasableClothingFigure';
|
||||
export * from './dedupeBadges';
|
||||
export * from './IAvatarEditorCategory';
|
||||
export * from './IAvatarEditorCategoryPartItem';
|
||||
|
||||
@@ -31,8 +31,14 @@ export interface CustomBadgeError
|
||||
|
||||
const interpolate = (value: string): string =>
|
||||
{
|
||||
try { return GetConfiguration().interpolate(value); }
|
||||
catch { return value; }
|
||||
try
|
||||
{
|
||||
return GetConfiguration().interpolate(value);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const getConfigUrl = (key: string, fallback: string): string =>
|
||||
@@ -61,8 +67,14 @@ const parseJson = async <T>(response: Response): Promise<T> =>
|
||||
{
|
||||
const text = await response.text();
|
||||
if(!text) return {} as T;
|
||||
try { return JSON.parse(text) as T; }
|
||||
catch { throw new Error('Invalid response from server.'); }
|
||||
try
|
||||
{
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw new Error('Invalid response from server.');
|
||||
}
|
||||
};
|
||||
|
||||
const throwOnError = async (response: Response): Promise<void> =>
|
||||
@@ -129,8 +141,14 @@ const injectTextsIntoLocalization = (texts: Record<string, string> | null | unde
|
||||
{
|
||||
if(!texts) return;
|
||||
let manager: ReturnType<typeof GetLocalizationManager> | null = null;
|
||||
try { manager = GetLocalizationManager(); }
|
||||
catch { return; }
|
||||
try
|
||||
{
|
||||
manager = GetLocalizationManager();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(!manager || typeof manager.setValue !== 'function') return;
|
||||
for(const key of Object.keys(texts))
|
||||
{
|
||||
@@ -152,7 +170,8 @@ export const ensureCustomBadgeTexts = (): Promise<void> =>
|
||||
const payload = await parseJson<{ texts: Record<string, string> }>(response);
|
||||
injectTextsIntoLocalization(payload.texts);
|
||||
}
|
||||
catch {}
|
||||
catch
|
||||
{}
|
||||
})();
|
||||
return customBadgeTextsLoadPromise;
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ export class CatalogNode implements ICatalogNode
|
||||
private _depth: number = 0;
|
||||
private _localization: string = '';
|
||||
private _pageId: number = -1;
|
||||
private _parentId: number = -1;
|
||||
private _pageName: string = '';
|
||||
private _iconId: number = 0;
|
||||
private _children: ICatalogNode[];
|
||||
@@ -21,6 +22,7 @@ export class CatalogNode implements ICatalogNode
|
||||
this._parent = parent;
|
||||
this._localization = node.localization;
|
||||
this._pageId = node.pageId;
|
||||
this._parentId = node.parentId;
|
||||
this._pageName = node.pageName;
|
||||
this._iconId = node.icon;
|
||||
this._children = [];
|
||||
@@ -82,6 +84,11 @@ export class CatalogNode implements ICatalogNode
|
||||
return this._pageId;
|
||||
}
|
||||
|
||||
public get parentId(): number
|
||||
{
|
||||
return this._parentId;
|
||||
}
|
||||
|
||||
public get pageName(): string
|
||||
{
|
||||
return this._pageName;
|
||||
|
||||
@@ -15,7 +15,7 @@ export class FurnitureOffer implements IPurchasableOffer
|
||||
constructor(furniData: IFurnitureData)
|
||||
{
|
||||
this._furniData = furniData;
|
||||
this._product = (new Product(this._furniData.type, this._furniData.id, this._furniData.customParams, 1, GetProductDataForLocalization(this._furniData.className), this._furniData) as IProduct);
|
||||
this._product = (new Product(this._furniData.type, this._furniData.id, this._furniData.customParams, 1, GetProductDataForLocalization(this._furniData.className), this._furniData));
|
||||
}
|
||||
|
||||
public activate(): void
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface ICatalogNode
|
||||
readonly isLeaf: boolean;
|
||||
readonly localization: string;
|
||||
readonly pageId: number;
|
||||
readonly parentId: number;
|
||||
readonly pageName: string;
|
||||
readonly iconId: number;
|
||||
readonly children: ICatalogNode[];
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { ClubGiftInfoParser, ClubOfferData, HabboGroupEntryData, MarketplaceConfigurationMessageParser } from '@nitrots/nitro-renderer';
|
||||
import { CatalogPetPalette } from './CatalogPetPalette';
|
||||
import { GiftWrappingConfiguration } from './GiftWrappingConfiguration';
|
||||
|
||||
export interface ICatalogOptions
|
||||
{
|
||||
groups?: HabboGroupEntryData[];
|
||||
petPalettes?: CatalogPetPalette[];
|
||||
clubOffers?: ClubOfferData[];
|
||||
clubOffersByWindowId?: Record<number, ClubOfferData[]>;
|
||||
clubGifts?: ClubGiftInfoParser;
|
||||
giftConfiguration?: GiftWrappingConfiguration;
|
||||
marketplaceConfiguration?: MarketplaceConfigurationMessageParser;
|
||||
}
|
||||
@@ -24,4 +24,5 @@ export interface IPurchasableOffer
|
||||
products: IProduct[];
|
||||
itemIds: string;
|
||||
haveOffer: boolean;
|
||||
clone?(): IPurchasableOffer;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ export * from './FurnitureOffer';
|
||||
export * from './GetImageIconUrlForProduct';
|
||||
export * from './GiftWrappingConfiguration';
|
||||
export * from './ICatalogNode';
|
||||
export * from './ICatalogOptions';
|
||||
export * from './ICatalogPage';
|
||||
export * from './IMarketplaceSearchOptions';
|
||||
export * from './IPageLocalization';
|
||||
|
||||
@@ -9,5 +9,5 @@ export const GetGroupChatData = (extraData: string) =>
|
||||
const figure = splitData[1];
|
||||
const userId = parseInt(splitData[2]);
|
||||
|
||||
return ({ username: username, figure: figure, userId: userId } as IGroupChatData);
|
||||
return ({ username: username, figure: figure, userId: userId });
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ export * from './purse';
|
||||
export * from './room';
|
||||
export * from './room/events';
|
||||
export * from './room/widgets';
|
||||
export * from './ui-settings';
|
||||
export * from './user';
|
||||
export * from './utils';
|
||||
export * from './wired';
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { GetCommunication, IMessageComposer, IMessageEvent, MessageEvent } from '@nitrots/nitro-renderer';
|
||||
import { QueryKey, useQuery, UseQueryOptions, UseQueryResult } from '@tanstack/react-query';
|
||||
import { SendMessageComposer } from '../nitro/SendMessageComposer';
|
||||
|
||||
export interface NitroQueryConfig<TParser extends IMessageEvent, TData>
|
||||
{
|
||||
/**
|
||||
* Stable key for caching/deduping. Convention:
|
||||
* `['nitro', '<domain>', '<request>', ...args]`.
|
||||
*/
|
||||
key: QueryKey;
|
||||
/**
|
||||
* Factory for the request composer. Called once per query execution.
|
||||
* `null` skips sending (useful when the server pushes the event
|
||||
* unprompted — you only want subscription, not a request).
|
||||
*/
|
||||
request: (() => IMessageComposer<unknown[]>) | null;
|
||||
/**
|
||||
* The parser class to listen for as the response.
|
||||
*/
|
||||
parser: typeof MessageEvent;
|
||||
/**
|
||||
* Maps the parser event to the data the component cares about.
|
||||
*/
|
||||
select?: (event: TParser) => TData;
|
||||
/**
|
||||
* Optional predicate to ignore parser events that don't match this
|
||||
* query (typically used as a correlation-key filter on a globally
|
||||
* shared event stream — e.g. `e => e.getParser()?.roomId === roomId`).
|
||||
* When the predicate returns false, the listener stays registered
|
||||
* and keeps waiting; the timeout still applies.
|
||||
*/
|
||||
accept?: (event: TParser) => boolean;
|
||||
/**
|
||||
* Max time to wait for the response before rejecting (default 15s).
|
||||
*/
|
||||
timeoutMs?: number;
|
||||
/**
|
||||
* Forwarded to TanStack Query.
|
||||
*/
|
||||
enabled?: boolean;
|
||||
staleTime?: number;
|
||||
refetchOnMount?: boolean | 'always';
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a Nitro composer/parser request-response pair as a TanStack Query
|
||||
* `useQuery` call. The returned object is the standard TanStack result —
|
||||
* `{ data, isLoading, isError, error, refetch, ... }`.
|
||||
*
|
||||
* Behavior:
|
||||
* - On the first subscribe, registers the parser, sends the composer,
|
||||
* resolves the Promise with the selected payload when the parser fires.
|
||||
* - Default `staleTime` is the QueryClient default (30s).
|
||||
* - Subsequent mounts within `staleTime` get the cached value immediately;
|
||||
* the request is NOT re-sent.
|
||||
* - Identical concurrent calls (same `key`) are deduped.
|
||||
*/
|
||||
export const useNitroQuery = <TParser extends IMessageEvent, TData = TParser>(
|
||||
config: NitroQueryConfig<TParser, TData>
|
||||
): UseQueryResult<TData> =>
|
||||
{
|
||||
const { key, request, parser, select, accept, timeoutMs = 15_000, enabled, staleTime, refetchOnMount } = config;
|
||||
|
||||
const options: UseQueryOptions<TData, Error, TData> = {
|
||||
queryKey: key,
|
||||
queryFn: () => awaitNitroResponse<TParser, TData>({ request, parser, select, accept, timeoutMs }),
|
||||
enabled,
|
||||
staleTime,
|
||||
refetchOnMount
|
||||
};
|
||||
|
||||
return useQuery(options);
|
||||
};
|
||||
|
||||
/**
|
||||
* Lower-level helper: send a composer (if any) and resolve with the next
|
||||
* matching parser event. Exposed so `queryClient.fetchQuery({...})` callers
|
||||
* can use the same plumbing imperatively.
|
||||
*/
|
||||
export const awaitNitroResponse = <TParser extends IMessageEvent, TData>(
|
||||
config: Pick<NitroQueryConfig<TParser, TData>, 'request' | 'parser' | 'select' | 'accept' | 'timeoutMs'>
|
||||
): Promise<TData> =>
|
||||
new Promise<TData>((resolve, reject) =>
|
||||
{
|
||||
const { request, parser: ParserCtor, select, accept, timeoutMs = 15_000 } = config;
|
||||
|
||||
let settled = false;
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
||||
let listener: IMessageEvent | undefined = undefined;
|
||||
|
||||
const cleanup = () =>
|
||||
{
|
||||
if(timeoutHandle !== null) clearTimeout(timeoutHandle);
|
||||
if(listener) GetCommunication().removeMessageEvent(listener);
|
||||
};
|
||||
|
||||
listener = new (ParserCtor as any)((event: TParser) =>
|
||||
{
|
||||
if(settled) return;
|
||||
if(accept && !accept(event)) return;
|
||||
settled = true;
|
||||
|
||||
cleanup();
|
||||
|
||||
try
|
||||
{
|
||||
resolve(select ? select(event) : (event as unknown as TData));
|
||||
}
|
||||
catch(err)
|
||||
{
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
});
|
||||
|
||||
GetCommunication().registerMessageEvent(listener);
|
||||
|
||||
timeoutHandle = setTimeout(() =>
|
||||
{
|
||||
if(settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(new Error(`NitroQuery timed out after ${ timeoutMs }ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
if(request) SendMessageComposer(request());
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './createNitroQuery';
|
||||
export * from './useNitroEventInvalidator';
|
||||
@@ -0,0 +1,48 @@
|
||||
import { IMessageEvent, MessageEvent } from '@nitrots/nitro-renderer';
|
||||
import { QueryKey, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMessageEvent } from '../../hooks/events/useMessageEvent';
|
||||
|
||||
/**
|
||||
* Invalidate a TanStack query slot every time the renderer pushes the
|
||||
* matching parser event. Companion to useNitroQuery for the case where
|
||||
* the server can push fresh data unprompted (e.g. ClubGiftInfoEvent
|
||||
* fires both as the response to GetClubGiftInfo and again after the
|
||||
* user claims a gift via SelectClubGiftComposer).
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* const { data: clubGifts } = useNitroQuery({
|
||||
* key: ['nitro', 'catalog', 'clubGifts'],
|
||||
* request: () => new GetClubGiftInfo(),
|
||||
* parser: ClubGiftInfoEvent,
|
||||
* select: e => e.getParser(),
|
||||
* });
|
||||
*
|
||||
* // re-fetch on every server push:
|
||||
* useNitroEventInvalidator(ClubGiftInfoEvent, ['nitro', 'catalog', 'clubGifts']);
|
||||
*
|
||||
* Optional `accept` predicate filters out events that don't belong to
|
||||
* this query slot — useful when the same parser is multiplexed across
|
||||
* multiple correlated queries (mirrors useNitroQuery.accept).
|
||||
*
|
||||
* Implementation: the renderer push triggers `queryClient.invalidateQueries`,
|
||||
* which marks the slot stale; the next subscriber render triggers a
|
||||
* fresh fetch via useNitroQuery's queryFn. If nobody is currently
|
||||
* subscribed, the invalidation is a no-op (TanStack drops stale entries
|
||||
* with no active observers per its garbage-collection policy).
|
||||
*/
|
||||
export const useNitroEventInvalidator = <T extends IMessageEvent>(
|
||||
eventType: typeof MessageEvent,
|
||||
queryKey: QueryKey,
|
||||
accept?: (event: T) => boolean
|
||||
) =>
|
||||
{
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useMessageEvent<T>(eventType, event =>
|
||||
{
|
||||
if(accept && !accept(event)) return;
|
||||
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
});
|
||||
};
|
||||
@@ -20,10 +20,11 @@ export class AvatarInfoUser implements IAvatarInfo
|
||||
public prefixFont: string = '';
|
||||
public displayOrder: string = 'icon-prefix-name';
|
||||
public achievementScore: number = 0;
|
||||
public backgroundId: number = 0;
|
||||
public backgroundId: number = 0;
|
||||
public standId: number = 0;
|
||||
public overlayId: number = 0;
|
||||
public cardBackgroundId: number = 0;
|
||||
public borderId: number = 0;
|
||||
public webID: number = 0;
|
||||
public xp: number = 0;
|
||||
public userType: number = -1;
|
||||
|
||||
@@ -190,10 +190,11 @@ export class AvatarInfoUtilities
|
||||
userInfo.prefixEffect = userData.prefixEffect;
|
||||
userInfo.prefixFont = userData.prefixFont;
|
||||
userInfo.displayOrder = userData.displayOrder;
|
||||
userInfo.backgroundId = userData.background;
|
||||
userInfo.backgroundId = userData.background;
|
||||
userInfo.standId = userData.stand;
|
||||
userInfo.overlayId = userData.overlay;
|
||||
userInfo.cardBackgroundId = userData.cardBackground ?? 0;
|
||||
userInfo.borderId = (userData as any).borderId ?? 0;
|
||||
userInfo.achievementScore = userData.activityPoints;
|
||||
userInfo.webID = userData.webID;
|
||||
userInfo.roomIndex = userData.roomIndex;
|
||||
|
||||
@@ -9,9 +9,11 @@ export class chooserSelectionVisualizer
|
||||
{
|
||||
if (this.animationFrameId !== null) return;
|
||||
|
||||
const animate = (time: number) => {
|
||||
const animate = (time: number) =>
|
||||
{
|
||||
const elapsed = time / 1000; // Convert to seconds
|
||||
this.activeFilters.forEach(filter => {
|
||||
this.activeFilters.forEach(filter =>
|
||||
{
|
||||
filter.time = elapsed; // Update time uniform
|
||||
});
|
||||
this.animationFrameId = requestAnimationFrame(animate);
|
||||
@@ -22,7 +24,8 @@ export class chooserSelectionVisualizer
|
||||
|
||||
private static stopAnimation(): void
|
||||
{
|
||||
if (this.animationFrameId !== null) {
|
||||
if (this.animationFrameId !== null)
|
||||
{
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
this.animationFrameId = null;
|
||||
}
|
||||
@@ -45,7 +48,7 @@ export class chooserSelectionVisualizer
|
||||
|
||||
for (const sprite of visualization.sprites)
|
||||
{
|
||||
if (sprite.blendMode === 1) continue;
|
||||
if (sprite.blendMode === 'add') continue;
|
||||
const existing = (sprite.filters || []).filter(f => !(f instanceof ChooserSelectionFilter));
|
||||
sprite.filters = [...existing, filter];
|
||||
}
|
||||
@@ -69,7 +72,8 @@ export class chooserSelectionVisualizer
|
||||
sprite.filters = (sprite.filters || []).filter(f => !(f instanceof ChooserSelectionFilter));
|
||||
}
|
||||
|
||||
if (this.activeFilters.size === 0) {
|
||||
if (this.activeFilters.size === 0)
|
||||
{
|
||||
this.stopAnimation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { AvatarFigurePartType, GetAvatarRenderManager, IAvatarFigureContainer }
|
||||
|
||||
export class MannequinUtilities
|
||||
{
|
||||
public static MANNEQUIN_FIGURE = [ 'hd', 99999, [ 99998 ] ];
|
||||
public static MANNEQUIN_FIGURE: [ string, number, number[] ] = [ 'hd', 99999, [ 99998 ] ];
|
||||
public static MANNEQUIN_CLOTHING_PART_TYPES = [
|
||||
AvatarFigurePartType.CHEST_ACCESSORY,
|
||||
AvatarFigurePartType.COAT_CHEST,
|
||||
@@ -33,6 +33,6 @@ export class MannequinUtilities
|
||||
figureContainer.removePart(part);
|
||||
}
|
||||
|
||||
figureContainer.updatePart((this.MANNEQUIN_FIGURE[0] as string), (this.MANNEQUIN_FIGURE[1] as number), (this.MANNEQUIN_FIGURE[2] as number[]));
|
||||
figureContainer.updatePart((this.MANNEQUIN_FIGURE[0]), (this.MANNEQUIN_FIGURE[1]), (this.MANNEQUIN_FIGURE[2]));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { GetCommunication, UiSettingsDataEvent, UiSettingsLoadComposer, UiSettingsSaveComposer } from '@nitrots/nitro-renderer';
|
||||
import { createContext, FC, PropsWithChildren, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { createContext, FC, PropsWithChildren, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { DEFAULT_UI_SETTINGS, IUiSettings } from './IUiSettings';
|
||||
|
||||
/**
|
||||
* UI settings currently persist to localStorage only. The cross-device
|
||||
* server-side sync (UiSettingsLoadComposer / UiSettingsSaveComposer /
|
||||
* UiSettingsDataEvent) is a planned addition that requires both the
|
||||
* renderer composer classes and the Arcturus packet handlers — none of
|
||||
* which exist yet. Until those land, settings stay per-browser.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'nitro.ui.settings';
|
||||
|
||||
interface IUiSettingsContext
|
||||
@@ -18,8 +25,10 @@ interface IUiSettingsContext
|
||||
const UiSettingsContext = createContext<IUiSettingsContext>({
|
||||
settings: DEFAULT_UI_SETTINGS,
|
||||
isCustomActive: false,
|
||||
updateSettings: () => {},
|
||||
resetSettings: () => {},
|
||||
updateSettings: () =>
|
||||
{},
|
||||
resetSettings: () =>
|
||||
{},
|
||||
getHeaderStyle: () => ({}),
|
||||
getTabsStyle: () => ({}),
|
||||
getAccentColor: () => DEFAULT_UI_SETTINGS.headerColor
|
||||
@@ -42,7 +51,8 @@ const loadSettings = (): IUiSettings =>
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if(stored) return { ...DEFAULT_UI_SETTINGS, ...JSON.parse(stored) };
|
||||
}
|
||||
catch(e) {}
|
||||
catch(e)
|
||||
{}
|
||||
|
||||
return { ...DEFAULT_UI_SETTINGS };
|
||||
};
|
||||
@@ -53,61 +63,20 @@ const saveSettings = (settings: IUiSettings): void =>
|
||||
{
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
}
|
||||
catch(e) {}
|
||||
catch(e)
|
||||
{}
|
||||
};
|
||||
|
||||
const sendComposer = (composer: any): void =>
|
||||
{
|
||||
try
|
||||
{
|
||||
GetCommunication()?.connection?.send(composer);
|
||||
}
|
||||
catch(e) {}
|
||||
};
|
||||
const ALL_CSS_VARS = [
|
||||
'--ui-accent-color', '--ui-accent-dark',
|
||||
'--ui-ctx-bg', '--ui-ctx-header-bg', '--ui-ctx-item-bg1', '--ui-ctx-item-bg2',
|
||||
'--ui-btn-primary-bg', '--ui-btn-primary-border',
|
||||
'--ui-dark-bg', '--ui-dark-border'
|
||||
];
|
||||
|
||||
export const UiSettingsProvider: FC<PropsWithChildren> = ({ children }) =>
|
||||
{
|
||||
const [ settings, setSettings ] = useState<IUiSettings>(loadSettings);
|
||||
const serverSaveTimerRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
|
||||
// Carica dal server al mount e ascolta risposta
|
||||
useEffect(() =>
|
||||
{
|
||||
sendComposer(new UiSettingsLoadComposer());
|
||||
|
||||
const connection = GetCommunication()?.connection;
|
||||
|
||||
if(!connection) return;
|
||||
|
||||
const handler = (event: any) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const parser = event.getParser();
|
||||
const json = parser?.settingsJson;
|
||||
|
||||
if(json && json !== '{}')
|
||||
{
|
||||
const serverSettings = { ...DEFAULT_UI_SETTINGS, ...JSON.parse(json) };
|
||||
setSettings(serverSettings);
|
||||
saveSettings(serverSettings);
|
||||
}
|
||||
}
|
||||
catch(e) {}
|
||||
};
|
||||
|
||||
connection.addMessageEvent(new UiSettingsDataEvent(handler));
|
||||
}, []);
|
||||
|
||||
const syncToServer = useCallback((settingsToSave: IUiSettings) =>
|
||||
{
|
||||
if(serverSaveTimerRef.current) clearTimeout(serverSaveTimerRef.current);
|
||||
|
||||
serverSaveTimerRef.current = setTimeout(() =>
|
||||
{
|
||||
sendComposer(new UiSettingsSaveComposer(JSON.stringify(settingsToSave)));
|
||||
}, 1000);
|
||||
}, []);
|
||||
|
||||
const updateSettings = useCallback((partial: Partial<IUiSettings>) =>
|
||||
{
|
||||
@@ -115,18 +84,16 @@ export const UiSettingsProvider: FC<PropsWithChildren> = ({ children }) =>
|
||||
{
|
||||
const updated = { ...prev, ...partial };
|
||||
saveSettings(updated);
|
||||
syncToServer(updated);
|
||||
|
||||
return updated;
|
||||
});
|
||||
}, [ syncToServer ]);
|
||||
}, []);
|
||||
|
||||
const resetSettings = useCallback(() =>
|
||||
{
|
||||
setSettings({ ...DEFAULT_UI_SETTINGS });
|
||||
saveSettings(DEFAULT_UI_SETTINGS);
|
||||
syncToServer(DEFAULT_UI_SETTINGS);
|
||||
}, [ syncToServer ]);
|
||||
}, []);
|
||||
|
||||
const getHeaderStyle = useCallback((): React.CSSProperties =>
|
||||
{
|
||||
@@ -183,13 +150,6 @@ export const UiSettingsProvider: FC<PropsWithChildren> = ({ children }) =>
|
||||
|
||||
const isCustomActive = settings.colorMode !== 'default';
|
||||
|
||||
const ALL_CSS_VARS = [
|
||||
'--ui-accent-color', '--ui-accent-dark',
|
||||
'--ui-ctx-bg', '--ui-ctx-header-bg', '--ui-ctx-item-bg1', '--ui-ctx-item-bg2',
|
||||
'--ui-btn-primary-bg', '--ui-btn-primary-border',
|
||||
'--ui-dark-bg', '--ui-dark-border'
|
||||
];
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const root = document.documentElement;
|
||||
@@ -215,9 +175,9 @@ export const UiSettingsProvider: FC<PropsWithChildren> = ({ children }) =>
|
||||
}, [ settings ]);
|
||||
|
||||
return (
|
||||
<UiSettingsContext.Provider value={ { settings, isCustomActive, updateSettings, resetSettings, getHeaderStyle, getTabsStyle, getAccentColor } }>
|
||||
<UiSettingsContext value={ { settings, isCustomActive, updateSettings, resetSettings, getHeaderStyle, getTabsStyle, getAccentColor } }>
|
||||
{ children }
|
||||
</UiSettingsContext.Provider>
|
||||
</UiSettingsContext>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export class ProductImageUtility
|
||||
imageUrl = GetRoomEngine().getFurnitureFloorIconUrl(furniClassId);
|
||||
break;
|
||||
case FurnitureType.WALL:
|
||||
const productCategory = this.getProductCategory(CatalogPageMessageProductData.I, furniClassId);
|
||||
const productCategory = this.getProductCategory(productType, furniClassId);
|
||||
|
||||
if(productCategory === 1)
|
||||
{
|
||||
@@ -32,7 +32,7 @@ export class ProductImageUtility
|
||||
}
|
||||
}
|
||||
break;
|
||||
case FurnitureType.EFFECT:
|
||||
case FurnitureType.EFFECT:
|
||||
// fx_icon_furniClassId_png
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -53,8 +53,12 @@ export const SetRememberLogin = (data: RememberLoginData): void =>
|
||||
{
|
||||
if(!data?.token?.length && !data?.ssoTicket?.length) return;
|
||||
|
||||
try { window.localStorage.setItem(REMEMBER_LOGIN_KEY, JSON.stringify(data)); }
|
||||
catch {}
|
||||
try
|
||||
{
|
||||
window.localStorage.setItem(REMEMBER_LOGIN_KEY, JSON.stringify(data));
|
||||
}
|
||||
catch
|
||||
{}
|
||||
};
|
||||
|
||||
export const ClearRememberLogin = (): void =>
|
||||
@@ -64,7 +68,8 @@ export const ClearRememberLogin = (): void =>
|
||||
window.localStorage.removeItem(REMEMBER_LOGIN_KEY);
|
||||
window.localStorage.removeItem(LEGACY_REMEMBER_LOGIN_KEY);
|
||||
}
|
||||
catch {}
|
||||
catch
|
||||
{}
|
||||
};
|
||||
|
||||
export const StoreRememberLoginFromPayload = (payload: Record<string, unknown>, username?: string, ssoTicket?: string): void =>
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ColorUtils } from './ColorUtils';
|
||||
import { FixedSizeStack } from './FixedSizeStack';
|
||||
import { LocalizeFormattedNumber } from './LocalizeFormattedNumber';
|
||||
|
||||
describe('LocalizeFormattedNumber', () =>
|
||||
{
|
||||
it('returns "0" for zero / NaN / null / undefined', () =>
|
||||
{
|
||||
expect(LocalizeFormattedNumber(0)).toBe('0');
|
||||
expect(LocalizeFormattedNumber(NaN)).toBe('0');
|
||||
expect(LocalizeFormattedNumber(null)).toBe('0');
|
||||
expect(LocalizeFormattedNumber(undefined as unknown as number)).toBe('0');
|
||||
});
|
||||
|
||||
it('keeps numbers under 1000 unchanged', () =>
|
||||
{
|
||||
expect(LocalizeFormattedNumber(42)).toBe('42');
|
||||
expect(LocalizeFormattedNumber(999)).toBe('999');
|
||||
});
|
||||
|
||||
it('inserts a thin space every 3 digits for >=1000', () =>
|
||||
{
|
||||
expect(LocalizeFormattedNumber(1000)).toBe('1 000');
|
||||
expect(LocalizeFormattedNumber(1_234_567)).toBe('1 234 567');
|
||||
expect(LocalizeFormattedNumber(10_000_000)).toBe('10 000 000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ColorUtils', () =>
|
||||
{
|
||||
describe('makeColorHex', () =>
|
||||
{
|
||||
it('prepends "#" to the given color string', () =>
|
||||
{
|
||||
expect(ColorUtils.makeColorHex('ff0000')).toBe('#ff0000');
|
||||
expect(ColorUtils.makeColorHex('abc')).toBe('#abc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeColorNumberHex', () =>
|
||||
{
|
||||
it('pads to 6 hex chars and prepends "#"', () =>
|
||||
{
|
||||
expect(ColorUtils.makeColorNumberHex(0xff0000)).toBe('#ff0000');
|
||||
expect(ColorUtils.makeColorNumberHex(0x00ff00)).toBe('#00ff00');
|
||||
expect(ColorUtils.makeColorNumberHex(0)).toBe('#000000');
|
||||
});
|
||||
|
||||
it('pads short hex values with leading zeros', () =>
|
||||
{
|
||||
expect(ColorUtils.makeColorNumberHex(0xff)).toBe('#0000ff');
|
||||
expect(ColorUtils.makeColorNumberHex(1)).toBe('#000001');
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertFromHex', () =>
|
||||
{
|
||||
it('parses a "#"-prefixed hex string to a number', () =>
|
||||
{
|
||||
expect(ColorUtils.convertFromHex('#ff0000')).toBe(0xff0000);
|
||||
expect(ColorUtils.convertFromHex('#000000')).toBe(0);
|
||||
expect(ColorUtils.convertFromHex('#ffffff')).toBe(0xffffff);
|
||||
});
|
||||
|
||||
it('also handles strings without the leading "#"', () =>
|
||||
{
|
||||
expect(ColorUtils.convertFromHex('00ff00')).toBe(0x00ff00);
|
||||
});
|
||||
});
|
||||
|
||||
describe('int_to_8BitVals / eight_bitVals_to_int', () =>
|
||||
{
|
||||
it('roundtrips: int -> [a,r,g,b] -> int', () =>
|
||||
{
|
||||
const original = 0x12345678;
|
||||
const [ a, b, c, d ] = ColorUtils.int_to_8BitVals(original);
|
||||
expect(a).toBe(0x12);
|
||||
expect(b).toBe(0x34);
|
||||
expect(c).toBe(0x56);
|
||||
expect(d).toBe(0x78);
|
||||
expect(ColorUtils.eight_bitVals_to_int(a, b, c, d)).toBe(original);
|
||||
});
|
||||
|
||||
it('roundtrips zero', () =>
|
||||
{
|
||||
const parts = ColorUtils.int_to_8BitVals(0);
|
||||
expect(parts).toEqual([ 0, 0, 0, 0 ]);
|
||||
expect(ColorUtils.eight_bitVals_to_int(0, 0, 0, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('int2rgb', () =>
|
||||
{
|
||||
it('produces rgba(r,g,b,1) for an RGB integer', () =>
|
||||
{
|
||||
expect(ColorUtils.int2rgb(0xff0000)).toBe('rgba(255,0,0,1)');
|
||||
expect(ColorUtils.int2rgb(0x00ff00)).toBe('rgba(0,255,0,1)');
|
||||
expect(ColorUtils.int2rgb(0x0000ff)).toBe('rgba(0,0,255,1)');
|
||||
});
|
||||
|
||||
it('returns black for 0', () =>
|
||||
{
|
||||
expect(ColorUtils.int2rgb(0)).toBe('rgba(0,0,0,1)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FixedSizeStack', () =>
|
||||
{
|
||||
it('grows up to maxSize then overwrites the oldest entry', () =>
|
||||
{
|
||||
const stack = new FixedSizeStack(3);
|
||||
|
||||
stack.addValue(10);
|
||||
stack.addValue(20);
|
||||
stack.addValue(30);
|
||||
|
||||
expect(stack.getMax()).toBe(30);
|
||||
expect(stack.getMin()).toBe(10);
|
||||
|
||||
// Capacity hit — 40 overwrites 10
|
||||
stack.addValue(40);
|
||||
expect(stack.getMin()).toBe(20);
|
||||
expect(stack.getMax()).toBe(40);
|
||||
|
||||
// 50 overwrites 20
|
||||
stack.addValue(50);
|
||||
expect(stack.getMin()).toBe(30);
|
||||
expect(stack.getMax()).toBe(50);
|
||||
});
|
||||
|
||||
it('reset clears all values', () =>
|
||||
{
|
||||
const stack = new FixedSizeStack(2);
|
||||
|
||||
stack.addValue(100);
|
||||
stack.addValue(200);
|
||||
|
||||
expect(stack.getMax()).toBe(200);
|
||||
|
||||
stack.reset();
|
||||
|
||||
stack.addValue(7);
|
||||
expect(stack.getMax()).toBe(7);
|
||||
expect(stack.getMin()).toBe(7);
|
||||
});
|
||||
|
||||
it('getMax with maxSize > inserted entries returns the inserted value', () =>
|
||||
{
|
||||
// FixedSizeStack iterates the whole maxSize window but the
|
||||
// unfilled slots are `undefined` which fail `> currentMax`, so
|
||||
// the inserted value wins.
|
||||
const stack = new FixedSizeStack(5);
|
||||
stack.addValue(42);
|
||||
|
||||
expect(stack.getMax()).toBe(42);
|
||||
});
|
||||
|
||||
it('getMax on an empty stack returns Number.MIN_VALUE', () =>
|
||||
{
|
||||
const stack = new FixedSizeStack(3);
|
||||
expect(stack.getMax()).toBe(Number.MIN_VALUE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CloneObject } from './CloneObject';
|
||||
import { ConvertSeconds } from './ConvertSeconds';
|
||||
import { LocalizeShortNumber } from './LocalizeShortNumber';
|
||||
import { GetWiredTimeLocale } from '../wired/GetWiredTimeLocale';
|
||||
import { WiredDateToString } from '../wired/WiredDateToString';
|
||||
import { getPrefixFontStyle, parsePrefixColors, PRESET_PREFIX_FONTS } from './PrefixUtils';
|
||||
|
||||
describe('ConvertSeconds', () =>
|
||||
{
|
||||
it('formats zero seconds as the dd:hh:mm:ss zero string', () =>
|
||||
{
|
||||
expect(ConvertSeconds(0)).toBe('00:00:00:00');
|
||||
});
|
||||
|
||||
it('formats one minute correctly', () =>
|
||||
{
|
||||
expect(ConvertSeconds(60)).toBe('00:00:01:00');
|
||||
});
|
||||
|
||||
it('formats one hour correctly', () =>
|
||||
{
|
||||
expect(ConvertSeconds(3600)).toBe('00:01:00:00');
|
||||
});
|
||||
|
||||
it('formats one day correctly', () =>
|
||||
{
|
||||
expect(ConvertSeconds(86400)).toBe('01:00:00:00');
|
||||
});
|
||||
|
||||
it('formats a mixed value (1d 2h 3m 4s)', () =>
|
||||
{
|
||||
expect(ConvertSeconds(86400 + 2 * 3600 + 3 * 60 + 4)).toBe('01:02:03:04');
|
||||
});
|
||||
|
||||
it('pads single-digit components with a leading zero', () =>
|
||||
{
|
||||
expect(ConvertSeconds(9)).toBe('00:00:00:09');
|
||||
});
|
||||
});
|
||||
|
||||
describe('LocalizeShortNumber', () =>
|
||||
{
|
||||
it('returns "0" for zero, null, undefined, and NaN', () =>
|
||||
{
|
||||
expect(LocalizeShortNumber(0)).toBe('0');
|
||||
expect(LocalizeShortNumber(NaN)).toBe('0');
|
||||
expect(LocalizeShortNumber(null)).toBe('0');
|
||||
expect(LocalizeShortNumber(undefined as unknown as number)).toBe('0');
|
||||
});
|
||||
|
||||
it('keeps numbers safely under 1000 unchanged (returns as-is)', () =>
|
||||
{
|
||||
expect(LocalizeShortNumber(42)).toBe('42');
|
||||
// Anything that rounds to >= 1.0K (i.e. >= 950) crosses into the K bucket
|
||||
expect(LocalizeShortNumber(949)).toBe('949');
|
||||
});
|
||||
|
||||
it('rounds 950..999 up into the K bucket (documented quirk)', () =>
|
||||
{
|
||||
expect(LocalizeShortNumber(950)).toBe('1K');
|
||||
expect(LocalizeShortNumber(999)).toBe('1K');
|
||||
});
|
||||
|
||||
it('uses K for thousands', () =>
|
||||
{
|
||||
expect(LocalizeShortNumber(1500)).toBe('1.5K');
|
||||
expect(LocalizeShortNumber(12_345)).toBe('12.3K');
|
||||
});
|
||||
|
||||
it('uses M for millions', () =>
|
||||
{
|
||||
expect(LocalizeShortNumber(2_500_000)).toBe('2.5M');
|
||||
});
|
||||
|
||||
it('uses B for billions', () =>
|
||||
{
|
||||
expect(LocalizeShortNumber(3_700_000_000)).toBe('3.7B');
|
||||
});
|
||||
|
||||
it('preserves the sign for negative values', () =>
|
||||
{
|
||||
expect(LocalizeShortNumber(-1500)).toBe('-1.5K');
|
||||
expect(LocalizeShortNumber(-2_500_000)).toBe('-2.5M');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CloneObject', () =>
|
||||
{
|
||||
it('returns primitives unchanged', () =>
|
||||
{
|
||||
expect(CloneObject(42)).toBe(42);
|
||||
expect(CloneObject('hello')).toBe('hello');
|
||||
expect(CloneObject(null)).toBe(null);
|
||||
expect(CloneObject(undefined)).toBe(undefined);
|
||||
});
|
||||
|
||||
it('returns a new object instance for object inputs', () =>
|
||||
{
|
||||
const original = { a: 1, b: 'two' };
|
||||
const copy = CloneObject(original);
|
||||
|
||||
expect(copy).not.toBe(original);
|
||||
expect(copy).toEqual(original);
|
||||
});
|
||||
|
||||
it('preserves enumerable own keys', () =>
|
||||
{
|
||||
const original = { x: 1, y: 2, z: 3 };
|
||||
const copy = CloneObject(original);
|
||||
|
||||
expect(copy.x).toBe(1);
|
||||
expect(copy.y).toBe(2);
|
||||
expect(copy.z).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetWiredTimeLocale', () =>
|
||||
{
|
||||
// The renderer encodes time as `value = seconds * 2` so even values
|
||||
// are whole seconds, odd values are half-seconds.
|
||||
|
||||
it('returns "0" for value 0', () =>
|
||||
{
|
||||
expect(GetWiredTimeLocale(0)).toBe('0');
|
||||
});
|
||||
|
||||
it('returns whole seconds for even values', () =>
|
||||
{
|
||||
expect(GetWiredTimeLocale(2)).toBe('1');
|
||||
expect(GetWiredTimeLocale(10)).toBe('5');
|
||||
expect(GetWiredTimeLocale(60)).toBe('30');
|
||||
});
|
||||
|
||||
it('returns half-second formatting for odd values', () =>
|
||||
{
|
||||
expect(GetWiredTimeLocale(1)).toBe('0.5');
|
||||
expect(GetWiredTimeLocale(3)).toBe('1.5');
|
||||
expect(GetWiredTimeLocale(11)).toBe('5.5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WiredDateToString', () =>
|
||||
{
|
||||
it('zero-pads single-digit month / day / hour / minute', () =>
|
||||
{
|
||||
const d = new Date(2024, 0, 5, 7, 9); // Jan 5, 2024, 07:09
|
||||
expect(WiredDateToString(d)).toBe('2024/01/05 07:09');
|
||||
});
|
||||
|
||||
it('formats two-digit values without extra padding', () =>
|
||||
{
|
||||
const d = new Date(2024, 11, 31, 23, 59); // Dec 31, 2024, 23:59
|
||||
expect(WiredDateToString(d)).toBe('2024/12/31 23:59');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PrefixUtils.parsePrefixColors', () =>
|
||||
{
|
||||
it('returns an empty array when text or colors are empty', () =>
|
||||
{
|
||||
expect(parsePrefixColors('', '#fff')).toEqual([]);
|
||||
expect(parsePrefixColors('abc', '')).toEqual([]);
|
||||
});
|
||||
|
||||
it('maps each text character to the nth color', () =>
|
||||
{
|
||||
expect(parsePrefixColors('ab', '#f00,#0f0')).toEqual([ '#f00', '#0f0' ]);
|
||||
});
|
||||
|
||||
it('reuses the last color when the text is longer than the color list', () =>
|
||||
{
|
||||
expect(parsePrefixColors('abcd', '#f00,#0f0')).toEqual([ '#f00', '#0f0', '#0f0', '#0f0' ]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PrefixUtils.getPrefixFontStyle', () =>
|
||||
{
|
||||
it('returns an empty object for the default (empty) font id', () =>
|
||||
{
|
||||
expect(getPrefixFontStyle('')).toEqual({});
|
||||
});
|
||||
|
||||
it('returns a fontFamily for a known preset', () =>
|
||||
{
|
||||
const out = getPrefixFontStyle('pixel');
|
||||
expect(out.fontFamily).toBe(PRESET_PREFIX_FONTS.find(p => p.id === 'pixel')?.family);
|
||||
});
|
||||
|
||||
it('returns an empty object for an unknown font id', () =>
|
||||
{
|
||||
expect(getPrefixFontStyle('does-not-exist')).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* Mock LocalizeText (which transitively imports @nitrots/nitro-renderer)
|
||||
* with a deterministic stub. The stub returns `key|amount` so each test
|
||||
* can assert both the bucket FriendlyTime chose AND the value it computed.
|
||||
*/
|
||||
vi.mock('./LocalizeText', () => ({
|
||||
LocalizeText: (key: string, _params?: string[], replacements?: string[]) =>
|
||||
`${ key }|${ replacements?.[0] ?? '' }`
|
||||
}));
|
||||
|
||||
import { FriendlyTime } from './FriendlyTime';
|
||||
|
||||
const MINUTE = 60;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
const MONTH = 30 * DAY;
|
||||
const YEAR = 365 * DAY;
|
||||
|
||||
describe('FriendlyTime.format', () =>
|
||||
{
|
||||
it('uses the seconds bucket for small values', () =>
|
||||
{
|
||||
expect(FriendlyTime.format(5)).toBe('friendlytime.seconds|5');
|
||||
expect(FriendlyTime.format(0)).toBe('friendlytime.seconds|0');
|
||||
});
|
||||
|
||||
it('uses the minutes bucket once we cross 3 * 60s (default threshold)', () =>
|
||||
{
|
||||
expect(FriendlyTime.format(4 * MINUTE)).toBe('friendlytime.minutes|4');
|
||||
expect(FriendlyTime.format(10 * MINUTE)).toBe('friendlytime.minutes|10');
|
||||
});
|
||||
|
||||
it('uses the hours bucket above 3 * HOUR', () =>
|
||||
{
|
||||
expect(FriendlyTime.format(4 * HOUR)).toBe('friendlytime.hours|4');
|
||||
});
|
||||
|
||||
it('uses the days bucket above 3 * DAY', () =>
|
||||
{
|
||||
expect(FriendlyTime.format(5 * DAY)).toBe('friendlytime.days|5');
|
||||
});
|
||||
|
||||
it('uses the months bucket above 3 * MONTH', () =>
|
||||
{
|
||||
expect(FriendlyTime.format(4 * MONTH)).toBe('friendlytime.months|4');
|
||||
});
|
||||
|
||||
it('uses the years bucket above 3 * YEAR', () =>
|
||||
{
|
||||
expect(FriendlyTime.format(4 * YEAR)).toBe('friendlytime.years|4');
|
||||
});
|
||||
|
||||
it('rounds half-hours correctly inside the hours bucket', () =>
|
||||
{
|
||||
// 4.5 hours -> rounds to 5
|
||||
expect(FriendlyTime.format((4 * HOUR) + (30 * MINUTE))).toBe('friendlytime.hours|5');
|
||||
});
|
||||
|
||||
it('threshold=1 lets the larger bucket win sooner', () =>
|
||||
{
|
||||
// With default threshold=3, 90s would stay in "seconds"; with threshold=1
|
||||
// it crosses into "minutes" (90s > 1*60s).
|
||||
expect(FriendlyTime.format(90, '', 1)).toBe('friendlytime.minutes|2');
|
||||
});
|
||||
|
||||
it('key suffix is appended to the bucket key', () =>
|
||||
{
|
||||
// Useful for plurals / variants ('s' for singular fallback, etc.)
|
||||
expect(FriendlyTime.format(5, '.foo')).toBe('friendlytime.seconds.foo|5');
|
||||
expect(FriendlyTime.format(4 * HOUR, '.foo')).toBe('friendlytime.hours.foo|4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FriendlyTime.shortFormat', () =>
|
||||
{
|
||||
it('uses the .short variant of each bucket', () =>
|
||||
{
|
||||
expect(FriendlyTime.shortFormat(5)).toBe('friendlytime.seconds.short|5');
|
||||
expect(FriendlyTime.shortFormat(4 * MINUTE)).toBe('friendlytime.minutes.short|4');
|
||||
expect(FriendlyTime.shortFormat(4 * HOUR)).toBe('friendlytime.hours.short|4');
|
||||
expect(FriendlyTime.shortFormat(5 * DAY)).toBe('friendlytime.days.short|5');
|
||||
expect(FriendlyTime.shortFormat(4 * MONTH)).toBe('friendlytime.months.short|4');
|
||||
expect(FriendlyTime.shortFormat(4 * YEAR)).toBe('friendlytime.years.short|4');
|
||||
});
|
||||
|
||||
it('respects the optional key suffix and threshold', () =>
|
||||
{
|
||||
expect(FriendlyTime.shortFormat(2 * MINUTE, '.bar', 1)).toBe('friendlytime.minutes.short.bar|2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FriendlyTime.getLocalization', () =>
|
||||
{
|
||||
it('formats an arbitrary key and amount with the (amount, AMOUNT) replacements', () =>
|
||||
{
|
||||
expect(FriendlyTime.getLocalization('whatever', 42)).toBe('whatever|42');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,7 @@
|
||||
let _youtubeEnabled = false;
|
||||
|
||||
export const getYoutubeRoomEnabled = () => _youtubeEnabled;
|
||||
export const setYoutubeRoomEnabled = (enabled: boolean) => { _youtubeEnabled = enabled; };
|
||||
export const setYoutubeRoomEnabled = (enabled: boolean) =>
|
||||
{
|
||||
_youtubeEnabled = enabled;
|
||||
};
|
||||
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
@@ -1,4 +1,4 @@
|
||||
const rawNickIcons = import.meta.glob('./*.gif', { eager: true, import: 'default' }) as Record<string, string>;
|
||||
const rawNickIcons = import.meta.glob('./*.gif', { eager: true, import: 'default' });
|
||||
|
||||
export const NICK_ICON_URLS: Record<string, string> = Object.entries(rawNickIcons).reduce((accumulator, [ path, url ]) =>
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { GetConfiguration } from '@nitrots/nitro-renderer';
|
||||
import JSON5 from 'json5';
|
||||
import { configFileUrl, getClientMode, installSecureFetch } from './secure-assets';
|
||||
|
||||
@@ -32,7 +33,6 @@ const ensureMobileViewport = () =>
|
||||
};
|
||||
|
||||
ensureMobileViewport();
|
||||
installSecureFetch();
|
||||
|
||||
const setBootDebug = (message: string) =>
|
||||
{
|
||||
@@ -43,11 +43,10 @@ const setBootDebug = (message: string) =>
|
||||
|
||||
if(secureNode) secureNode.textContent = `${ secureNode.textContent }\n${ message }`;
|
||||
}
|
||||
catch {}
|
||||
catch
|
||||
{}
|
||||
};
|
||||
|
||||
setBootDebug('boot: secure fetch installed');
|
||||
|
||||
const deployBaseUrl = (): string =>
|
||||
{
|
||||
try
|
||||
@@ -55,14 +54,16 @@ const deployBaseUrl = (): string =>
|
||||
const loaderBase = (window as any).__nitroLoaderBase;
|
||||
if(typeof loaderBase === 'string' && loaderBase.length) return new URL('..', loaderBase).toString();
|
||||
}
|
||||
catch {}
|
||||
catch
|
||||
{}
|
||||
|
||||
try
|
||||
{
|
||||
const moduleUrl = (import.meta as any).url;
|
||||
if(typeof moduleUrl === 'string' && moduleUrl.length) return new URL('..', new URL('.', moduleUrl)).toString();
|
||||
}
|
||||
catch {}
|
||||
catch
|
||||
{}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -73,7 +74,8 @@ const deployBaseUrl = (): string =>
|
||||
return trimmed ? `${ window.location.origin }/${ trimmed }/` : `${ window.location.origin }/`;
|
||||
}
|
||||
}
|
||||
catch {}
|
||||
catch
|
||||
{}
|
||||
|
||||
return `${ window.location.origin }/`;
|
||||
};
|
||||
@@ -123,6 +125,9 @@ const loadClientMode = async () =>
|
||||
|
||||
await loadClientMode();
|
||||
|
||||
installSecureFetch();
|
||||
setBootDebug('boot: secure fetch installed');
|
||||
|
||||
const search = new URLSearchParams(window.location.search);
|
||||
const clientMode = getClientMode();
|
||||
|
||||
@@ -141,6 +146,21 @@ const clientMode = getClientMode();
|
||||
|
||||
setBootDebug('boot: NitroConfig assigned');
|
||||
|
||||
// Load renderer-config.json + ui-config.json BEFORE rendering React. Otherwise
|
||||
// the first paint triggers a flood of "Missing configuration key" warnings for
|
||||
// every key components read synchronously (asset.url, login.endpoint, …) until
|
||||
// prepare()'s deferred init() finally lands. Doing it here makes the config
|
||||
// already populated by the time index.tsx mounts <App/>.
|
||||
try
|
||||
{
|
||||
await GetConfiguration().init();
|
||||
setBootDebug('boot: configuration init done');
|
||||
}
|
||||
catch(error)
|
||||
{
|
||||
setBootDebug(`boot: configuration init failed ${ error?.message || error }`);
|
||||
}
|
||||
|
||||
import('./index')
|
||||
.then(() => setBootDebug('boot: app bundle imported'))
|
||||
.catch(error =>
|
||||
|
||||
@@ -19,7 +19,7 @@ export const Button: FC<ButtonProps> = props =>
|
||||
|
||||
// fucked up method i know (i dont have a clue what im doing because im a ninja)
|
||||
|
||||
const newClassNames: string[] = [ 'pointer-events-auto inline-block font-normal leading-normal text-[#fff] text-center no-underline align-middle cursor-pointer select-none border border-[solid] border-transparent px-[.75rem] py-[.375rem] text-[.9rem] rounded-[.25rem] [transition:color_.15s_ease-in-out,background-color_.15s_ease-in-out,border-color_.15s_ease-in-out,box-shadow_.15s_ease-in-out]' ];
|
||||
const newClassNames: string[] = [ 'pointer-events-auto font-normal leading-normal text-[#fff] text-center no-underline cursor-pointer select-none border border-[solid] border-transparent px-[.75rem] py-[.375rem] text-[.9rem] rounded-[.25rem] [transition:color_.15s_ease-in-out,background-color_.15s_ease-in-out,border-color_.15s_ease-in-out,box-shadow_.15s_ease-in-out]' ];
|
||||
|
||||
if(variant)
|
||||
{
|
||||
@@ -44,9 +44,9 @@ export const Button: FC<ButtonProps> = props =>
|
||||
|
||||
if(variant == 'dark')
|
||||
newClassNames.push('text-white bg-dark [box-shadow:inset_0_2px_#ffffff26,inset_0_-2px_#0000001a,0_1px_#0000001a] hover:text-white hover:bg-[#18181bfb] hover:border-[#161619fb]');
|
||||
|
||||
if(variant == 'gray')
|
||||
newClassNames.push('text-white bg-[#1e7295] border-[#1e7295] [box-shadow:inset_0_2px_#ffffff26,inset_0_-2px_#0000001a,0_1px_#0000001a] hover:text-white hover:bg-[#1a617f] hover:border-[#185b77]');
|
||||
|
||||
if(variant == 'gray')
|
||||
newClassNames.push('text-white bg-[#1e7295] border-[#1e7295] [box-shadow:inset_0_2px_#ffffff26,inset_0_-2px_#0000001a,0_1px_#0000001a] hover:text-white hover:bg-[#1a617f] hover:border-[#185b77]');
|
||||
|
||||
}
|
||||
|
||||
@@ -67,5 +67,5 @@ export const Button: FC<ButtonProps> = props =>
|
||||
return newClassNames;
|
||||
}, [ variant, size, active, disabled, classNames ]);
|
||||
|
||||
return <Flex center classNames={ getClassNames } { ...rest } />;
|
||||
return <Flex center display="inline-flex" classNames={ getClassNames } { ...rest } />;
|
||||
};
|
||||
|
||||
@@ -19,4 +19,4 @@ export const ButtonGroup: FC<ButtonGroupProps> = props =>
|
||||
}, [ classNames ]);
|
||||
|
||||
return <Base classNames={ getClassNames } { ...rest } />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, FC, ProviderProps, useContext } from 'react';
|
||||
import { createContext, FC, ReactNode, useContext } from 'react';
|
||||
|
||||
export interface IGridContext
|
||||
{
|
||||
@@ -9,9 +9,9 @@ const GridContext = createContext<IGridContext>({
|
||||
isCssGrid: false
|
||||
});
|
||||
|
||||
export const GridContextProvider: FC<ProviderProps<IGridContext>> = props =>
|
||||
export const GridContextProvider: FC<{ value: IGridContext; children?: ReactNode }> = props =>
|
||||
{
|
||||
return <GridContext.Provider value={ props.value }>{ props.children }</GridContext.Provider>;
|
||||
return <GridContext value={ props.value }>{ props.children }</GridContext>;
|
||||
};
|
||||
|
||||
export const useGridContext = () => useContext(GridContext);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FC, PropsWithChildren, useEffect, useRef, useState } from 'react';
|
||||
import { FC, JSX, PropsWithChildren, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export const ReactPopover: FC<PropsWithChildren<{
|
||||
content: JSX.Element;
|
||||
|
||||
@@ -145,4 +145,4 @@ export const Slider: FC<SliderProps> = props =>
|
||||
) }
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -20,7 +20,8 @@ export interface TextProps extends BaseProps<HTMLDivElement> {
|
||||
textBreak?: boolean;
|
||||
}
|
||||
|
||||
export const Text: FC<TextProps> = props => {
|
||||
export const Text: FC<TextProps> = props =>
|
||||
{
|
||||
const {
|
||||
variant = 'black',
|
||||
fontWeight = null,
|
||||
@@ -40,20 +41,22 @@ export const Text: FC<TextProps> = props => {
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const getClassNames = useMemo(() => {
|
||||
const getClassNames = useMemo(() =>
|
||||
{
|
||||
const newClassNames: string[] = [truncate ? 'block' : 'inline'];
|
||||
|
||||
if (variant) {
|
||||
if (variant === 'primary') newClassNames.push('text-[#1e7295]');
|
||||
if (variant == 'secondary') newClassNames.push('text-[#185d79]');
|
||||
if (variant === 'black') newClassNames.push('text-[#000000]');
|
||||
if (variant == 'dark') newClassNames.push('text-[#18181b]');
|
||||
if (variant === 'gray') newClassNames.push('text-[#6b7280]');
|
||||
if (variant === 'white') newClassNames.push('text-[#ffffff]');
|
||||
if (variant == 'success') newClassNames.push('text-[#00800b]');
|
||||
if (variant == 'danger') newClassNames.push('text-[#a81a12]');
|
||||
if (variant == 'warning') newClassNames.push('text-[#ffc107]');
|
||||
}
|
||||
if (variant)
|
||||
{
|
||||
if (variant === 'primary') newClassNames.push('text-[#1e7295]');
|
||||
if (variant == 'secondary') newClassNames.push('text-[#185d79]');
|
||||
if (variant === 'black') newClassNames.push('text-[#000000]');
|
||||
if (variant == 'dark') newClassNames.push('text-[#18181b]');
|
||||
if (variant === 'gray') newClassNames.push('text-[#6b7280]');
|
||||
if (variant === 'white') newClassNames.push('text-[#ffffff]');
|
||||
if (variant == 'success') newClassNames.push('text-[#00800b]');
|
||||
if (variant == 'danger') newClassNames.push('text-[#a81a12]');
|
||||
if (variant == 'warning') newClassNames.push('text-[#ffc107]');
|
||||
}
|
||||
|
||||
if (bold) newClassNames.push('font-bold');
|
||||
if (fontWeight) newClassNames.push('font-' + fontWeight);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, FC, ProviderProps, useContext } from 'react';
|
||||
import { createContext, FC, ReactNode, useContext } from 'react';
|
||||
|
||||
interface INitroCardContext
|
||||
{
|
||||
@@ -9,9 +9,9 @@ const NitroCardContext = createContext<INitroCardContext>({
|
||||
theme: null
|
||||
});
|
||||
|
||||
export const NitroCardContextProvider: FC<ProviderProps<INitroCardContext>> = props =>
|
||||
export const NitroCardContextProvider: FC<{ value: INitroCardContext; children?: ReactNode }> = props =>
|
||||
{
|
||||
return <NitroCardContext.Provider value={ props.value }>{ props.children }</NitroCardContext.Provider>;
|
||||
return <NitroCardContext value={ props.value }>{ props.children }</NitroCardContext>;
|
||||
};
|
||||
|
||||
export const useNitroCardContext = () => useContext(NitroCardContext);
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface NitroCardViewProps extends DraggableWindowProps, ColumnProps
|
||||
export const NitroCardView: FC<NitroCardViewProps> = props =>
|
||||
{
|
||||
const { theme = 'primary', uniqueKey = null, handleSelector = '.drag-handler', windowPosition = DraggableWindowPosition.CENTER, disableDrag = false, overflow = 'hidden', position = 'relative', gap = 0, classNames = [], isResizable = true, ...rest } = props;
|
||||
const elementRef = useRef<HTMLDivElement>();
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const getClassNames = useMemo(() =>
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, Dispatch, FC, ProviderProps, SetStateAction, useContext } from 'react';
|
||||
import { createContext, Dispatch, FC, ReactNode, SetStateAction, useContext } from 'react';
|
||||
|
||||
export interface INitroCardAccordionContext
|
||||
{
|
||||
@@ -13,9 +13,9 @@ const NitroCardAccordionContext = createContext<INitroCardAccordionContext>({
|
||||
closeAll: null
|
||||
});
|
||||
|
||||
export const NitroCardAccordionContextProvider: FC<ProviderProps<INitroCardAccordionContext>> = props =>
|
||||
export const NitroCardAccordionContextProvider: FC<{ value: INitroCardAccordionContext; children?: ReactNode }> = props =>
|
||||
{
|
||||
return <NitroCardAccordionContext.Provider { ...props } />;
|
||||
return <NitroCardAccordionContext { ...props } />;
|
||||
};
|
||||
|
||||
export const useNitroCardAccordionContext = () => useContext(NitroCardAccordionContext);
|
||||
|
||||
@@ -21,7 +21,8 @@ export interface DraggableWindowProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const DraggableWindow: FC<DraggableWindowProps> = props => {
|
||||
export const DraggableWindow: FC<DraggableWindowProps> = props =>
|
||||
{
|
||||
const { uniqueKey = null, handleSelector = '.drag-handler', windowPosition = DraggableWindowPosition.CENTER, disableDrag = false, dragStyle = {}, children = null, offsetLeft = 0, offsetTop = 0 } = props;
|
||||
const [delta, setDelta] = useState<{ x: number, y: number }>({ x: 0, y: 0 });
|
||||
const [offset, setOffset] = useState<{ x: number, y: number }>({ x: 0, y: 0 });
|
||||
@@ -29,50 +30,61 @@ export const DraggableWindow: FC<DraggableWindowProps> = props => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isPositioned, setIsPositioned] = useState(false);
|
||||
const [dragHandler, setDragHandler] = useState<HTMLElement>(null);
|
||||
const elementRef = useRef<HTMLDivElement>();
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
const bringToTop = useCallback(() => {
|
||||
let zIndex = 400;
|
||||
for (const existingWindow of CURRENT_WINDOWS) {
|
||||
for (const existingWindow of CURRENT_WINDOWS)
|
||||
{
|
||||
zIndex += 1;
|
||||
existingWindow.style.zIndex = zIndex.toString();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const moveCurrentWindow = useCallback(() => {
|
||||
const moveCurrentWindow = useCallback(() =>
|
||||
{
|
||||
const index = CURRENT_WINDOWS.indexOf(elementRef.current);
|
||||
if (index === -1) {
|
||||
if (index === -1)
|
||||
{
|
||||
CURRENT_WINDOWS.push(elementRef.current);
|
||||
} else if (index === (CURRENT_WINDOWS.length - 1)) return;
|
||||
else if (index >= 0) {
|
||||
}
|
||||
else if (index === (CURRENT_WINDOWS.length - 1)) return;
|
||||
else if (index >= 0)
|
||||
{
|
||||
CURRENT_WINDOWS.splice(index, 1);
|
||||
CURRENT_WINDOWS.push(elementRef.current);
|
||||
}
|
||||
bringToTop();
|
||||
}, [bringToTop]);
|
||||
|
||||
const onMouseDown = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
const onMouseDown = useCallback((event: ReactMouseEvent<HTMLDivElement>) =>
|
||||
{
|
||||
moveCurrentWindow();
|
||||
}, [moveCurrentWindow]);
|
||||
|
||||
const onTouchStart = useCallback((event: ReactTouchEvent<HTMLDivElement>) => {
|
||||
const onTouchStart = useCallback((event: ReactTouchEvent<HTMLDivElement>) =>
|
||||
{
|
||||
moveCurrentWindow();
|
||||
}, [moveCurrentWindow]);
|
||||
|
||||
const startDragging = useCallback((startX: number, startY: number) => {
|
||||
const startDragging = useCallback((startX: number, startY: number) =>
|
||||
{
|
||||
setStart({ x: startX, y: startY });
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const onDragMouseDown = useCallback((event: MouseEvent) => {
|
||||
const onDragMouseDown = useCallback((event: MouseEvent) =>
|
||||
{
|
||||
startDragging(event.clientX, event.clientY);
|
||||
}, [startDragging]);
|
||||
|
||||
const onTouchDown = useCallback((event: TouchEvent) => {
|
||||
const onTouchDown = useCallback((event: TouchEvent) =>
|
||||
{
|
||||
const touch = event.touches[0];
|
||||
startDragging(touch.clientX, touch.clientY);
|
||||
}, [startDragging]);
|
||||
|
||||
const clampPosition = useCallback((newX: number, newY: number) => {
|
||||
const clampPosition = useCallback((newX: number, newY: number) =>
|
||||
{
|
||||
if (!elementRef.current) return { x: newX, y: newY };
|
||||
|
||||
const windowWidth = elementRef.current.offsetWidth;
|
||||
@@ -87,7 +99,8 @@ export const DraggableWindow: FC<DraggableWindowProps> = props => {
|
||||
return { x: clampedX, y: clampedY };
|
||||
}, []);
|
||||
|
||||
const onDragMouseMove = useCallback((event: MouseEvent) => {
|
||||
const onDragMouseMove = useCallback((event: MouseEvent) =>
|
||||
{
|
||||
if (!elementRef.current || !isDragging) return;
|
||||
|
||||
const newDeltaX = event.clientX - start.x;
|
||||
@@ -99,7 +112,8 @@ export const DraggableWindow: FC<DraggableWindowProps> = props => {
|
||||
setDelta({ x: clampedPos.x - offset.x, y: clampedPos.y - offset.y });
|
||||
}, [start, offset, clampPosition, isDragging]);
|
||||
|
||||
const onDragTouchMove = useCallback((event: TouchEvent) => {
|
||||
const onDragTouchMove = useCallback((event: TouchEvent) =>
|
||||
{
|
||||
if (!elementRef.current || !isDragging) return;
|
||||
|
||||
const touch = event.touches[0];
|
||||
@@ -112,7 +126,8 @@ export const DraggableWindow: FC<DraggableWindowProps> = props => {
|
||||
setDelta({ x: clampedPos.x - offset.x, y: clampedPos.y - offset.y });
|
||||
}, [start, offset, clampPosition, isDragging]);
|
||||
|
||||
const completeDrag = useCallback(() => {
|
||||
const completeDrag = useCallback(() =>
|
||||
{
|
||||
if (!elementRef.current || !dragHandler || !isDragging) return;
|
||||
|
||||
const finalOffsetX = offset.x + delta.x;
|
||||
@@ -123,29 +138,34 @@ export const DraggableWindow: FC<DraggableWindowProps> = props => {
|
||||
setOffset({ x: clampedPos.x, y: clampedPos.y });
|
||||
setIsDragging(false);
|
||||
|
||||
if (uniqueKey !== null) {
|
||||
const newStorage = { ...GetLocalStorage<WindowSaveOptions>(`nitro.windows.${uniqueKey}`) } as WindowSaveOptions;
|
||||
if (uniqueKey !== null)
|
||||
{
|
||||
const newStorage = { ...GetLocalStorage<WindowSaveOptions>(`nitro.windows.${uniqueKey}`) };
|
||||
newStorage.offset = { x: clampedPos.x, y: clampedPos.y };
|
||||
SetLocalStorage<WindowSaveOptions>(`nitro.windows.${uniqueKey}`, newStorage);
|
||||
}
|
||||
}, [dragHandler, delta, offset, uniqueKey, clampPosition, isDragging]);
|
||||
|
||||
const onDragMouseUp = useCallback((event: MouseEvent) => {
|
||||
const onDragMouseUp = useCallback((event: MouseEvent) =>
|
||||
{
|
||||
completeDrag();
|
||||
}, [completeDrag]);
|
||||
|
||||
const onDragTouchUp = useCallback((event: TouchEvent) => {
|
||||
const onDragTouchUp = useCallback((event: TouchEvent) =>
|
||||
{
|
||||
completeDrag();
|
||||
}, [completeDrag]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
useLayoutEffect(() =>
|
||||
{
|
||||
const element = elementRef.current as HTMLElement;
|
||||
if (!element) return;
|
||||
|
||||
CURRENT_WINDOWS.push(element);
|
||||
bringToTop();
|
||||
|
||||
if (!disableDrag) {
|
||||
if (!disableDrag)
|
||||
{
|
||||
const handle = element.querySelector(handleSelector);
|
||||
if (handle) setDragHandler(handle as HTMLElement);
|
||||
}
|
||||
@@ -155,7 +175,8 @@ export const DraggableWindow: FC<DraggableWindowProps> = props => {
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
|
||||
switch (windowPosition) {
|
||||
switch (windowPosition)
|
||||
{
|
||||
case DraggableWindowPosition.TOP_CENTER:
|
||||
offsetY = 50 + offsetTop;
|
||||
offsetX = (window.innerWidth - windowWidth) / 2 + offsetLeft;
|
||||
@@ -175,25 +196,29 @@ export const DraggableWindow: FC<DraggableWindowProps> = props => {
|
||||
setDelta({ x: 0, y: 0 });
|
||||
setIsPositioned(true);
|
||||
|
||||
return () => {
|
||||
return () =>
|
||||
{
|
||||
const index = CURRENT_WINDOWS.indexOf(element);
|
||||
if (index >= 0) CURRENT_WINDOWS.splice(index, 1);
|
||||
};
|
||||
}, [handleSelector, windowPosition, uniqueKey, disableDrag, offsetLeft, offsetTop, bringToTop]);
|
||||
|
||||
useEffect(() => {
|
||||
useEffect(() =>
|
||||
{
|
||||
if (!dragHandler) return;
|
||||
|
||||
dragHandler.addEventListener(MouseEventType.MOUSE_DOWN, onDragMouseDown);
|
||||
dragHandler.addEventListener(TouchEventType.TOUCH_START, onTouchDown);
|
||||
|
||||
return () => {
|
||||
return () =>
|
||||
{
|
||||
dragHandler.removeEventListener(MouseEventType.MOUSE_DOWN, onDragMouseDown);
|
||||
dragHandler.removeEventListener(TouchEventType.TOUCH_START, onTouchDown);
|
||||
};
|
||||
}, [dragHandler, onDragMouseDown, onTouchDown]);
|
||||
|
||||
useEffect(() => {
|
||||
useEffect(() =>
|
||||
{
|
||||
if (!isDragging) return;
|
||||
|
||||
document.addEventListener(MouseEventType.MOUSE_UP, onDragMouseUp);
|
||||
@@ -201,7 +226,8 @@ export const DraggableWindow: FC<DraggableWindowProps> = props => {
|
||||
document.addEventListener(MouseEventType.MOUSE_MOVE, onDragMouseMove);
|
||||
document.addEventListener(TouchEventType.TOUCH_MOVE, onDragTouchMove);
|
||||
|
||||
return () => {
|
||||
return () =>
|
||||
{
|
||||
document.removeEventListener(MouseEventType.MOUSE_UP, onDragMouseUp);
|
||||
document.removeEventListener(TouchEventType.TOUCH_END, onDragTouchUp);
|
||||
document.removeEventListener(MouseEventType.MOUSE_MOVE, onDragMouseMove);
|
||||
@@ -209,7 +235,8 @@ export const DraggableWindow: FC<DraggableWindowProps> = props => {
|
||||
};
|
||||
}, [isDragging, onDragMouseUp, onDragMouseMove, onDragTouchUp, onDragTouchMove]);
|
||||
|
||||
useEffect(() => {
|
||||
useEffect(() =>
|
||||
{
|
||||
if (!uniqueKey) return;
|
||||
|
||||
const localStorage = GetLocalStorage<WindowSaveOptions>(`nitro.windows.${uniqueKey}`);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { NitroLogger } from '@nitrots/nitro-renderer';
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { FC } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { WidgetErrorBoundary } from './WidgetErrorBoundary';
|
||||
|
||||
// `import { NitroLogger } from '@nitrots/nitro-renderer'` resolves to
|
||||
// `src/nitro-renderer.mock.ts` via the alias in vitest.config.mts.
|
||||
// The SUT imports the same path, so both reach the same vi.fn instance.
|
||||
|
||||
describe('WidgetErrorBoundary', () =>
|
||||
{
|
||||
beforeEach(() =>
|
||||
{
|
||||
vi.clearAllMocks();
|
||||
// react-error-boundary lets React's "uncaught error" log through
|
||||
// by default — silence it so jsdom doesn't dump a stack trace
|
||||
// every time we deliberately throw below.
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() =>
|
||||
{
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders its children when nothing throws', () =>
|
||||
{
|
||||
render(
|
||||
<WidgetErrorBoundary name="HappyPath">
|
||||
<span data-testid="child">visible</span>
|
||||
</WidgetErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('child')).toHaveTextContent('visible');
|
||||
});
|
||||
|
||||
it('swallows a render-time error to a silent fallback and logs it', () =>
|
||||
{
|
||||
const Boom: FC = () =>
|
||||
{
|
||||
throw new Error('kaboom');
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<WidgetErrorBoundary name="Boom">
|
||||
<Boom />
|
||||
</WidgetErrorBoundary>
|
||||
);
|
||||
|
||||
// Default fallback is `() => null` → boundary subtree is empty.
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
|
||||
expect(NitroLogger.error).toHaveBeenCalledTimes(1);
|
||||
const [ message, err ] = (NitroLogger.error as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(message).toBe('[Widget:Boom] crashed');
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect((err as Error).message).toBe('kaboom');
|
||||
});
|
||||
|
||||
it('renders a custom fallback node when provided', () =>
|
||||
{
|
||||
const Boom: FC = () =>
|
||||
{
|
||||
throw new Error('explode');
|
||||
};
|
||||
|
||||
render(
|
||||
<WidgetErrorBoundary name="WithFallback" fallback={ <div data-testid="fb">offline</div> }>
|
||||
<Boom />
|
||||
</WidgetErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('fb')).toHaveTextContent('offline');
|
||||
});
|
||||
|
||||
it('uses "unknown" as the widget name when the prop is omitted', () =>
|
||||
{
|
||||
const Boom: FC = () =>
|
||||
{
|
||||
throw new Error('anonymous');
|
||||
};
|
||||
|
||||
render(
|
||||
<WidgetErrorBoundary>
|
||||
<Boom />
|
||||
</WidgetErrorBoundary>
|
||||
);
|
||||
|
||||
expect(NitroLogger.error).toHaveBeenCalledTimes(1);
|
||||
expect((NitroLogger.error as ReturnType<typeof vi.fn>).mock.calls[0][0]).toBe('[Widget:unknown] crashed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NitroLogger } from '@nitrots/nitro-renderer';
|
||||
import { FC, ReactNode } from 'react';
|
||||
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
|
||||
|
||||
interface WidgetErrorBoundaryProps
|
||||
{
|
||||
name?: string;
|
||||
fallback?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const SilentFallback = (_props: FallbackProps) => null;
|
||||
|
||||
/**
|
||||
* Wraps a (room) widget so a runtime error inside it degrades gracefully
|
||||
* instead of unmounting the whole UI. Errors are logged to NitroLogger
|
||||
* with the widget name.
|
||||
*
|
||||
* Bonus addition from docs/ARCHITECTURE.md.
|
||||
*/
|
||||
export const WidgetErrorBoundary: FC<WidgetErrorBoundaryProps> = ({ name = 'unknown', fallback, children }) =>
|
||||
(
|
||||
<ErrorBoundary
|
||||
FallbackComponent={ fallback ? () => <>{ fallback }</> : SilentFallback }
|
||||
onError={ (err) => NitroLogger.error(`[Widget:${ name }] crashed`, err) }>
|
||||
{ children }
|
||||
</ErrorBoundary>
|
||||
);
|
||||
@@ -16,8 +16,9 @@ export * from './card';
|
||||
export * from './card/accordion';
|
||||
export * from './card/tabs';
|
||||
export * from './draggable-window';
|
||||
export * from './error-boundary/WidgetErrorBoundary';
|
||||
export * from './layout';
|
||||
export * from './layout/limited-edition';
|
||||
export * from './types';
|
||||
export * from "./Slider";
|
||||
export * from './Slider';
|
||||
export * from './utils';
|
||||
|
||||
@@ -20,6 +20,12 @@ export const LayoutAvatarImageView: FC<LayoutAvatarImageViewProps> = props =>
|
||||
const [ avatarUrl, setAvatarUrl ] = useState<string>(null);
|
||||
const [ isReady, setIsReady ] = useState<boolean>(false);
|
||||
const isDisposed = useRef(false);
|
||||
// Request id bumped on every prop change. The SDK can call
|
||||
// resetFigure asynchronously when server-side figure data lands;
|
||||
// if props change in quick succession the older callback could
|
||||
// otherwise overwrite the newer image. The closure captures the
|
||||
// id and bails when stale.
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
const getClassNames = useMemo(() =>
|
||||
{
|
||||
@@ -52,6 +58,7 @@ export const LayoutAvatarImageView: FC<LayoutAvatarImageViewProps> = props =>
|
||||
{
|
||||
if(!isReady) return;
|
||||
|
||||
const requestId = ++requestIdRef.current;
|
||||
const figureKey = [ figure, gender, direction, headOnly ].join('-');
|
||||
|
||||
if(AVATAR_IMAGE_CACHE.has(figureKey))
|
||||
@@ -62,7 +69,7 @@ export const LayoutAvatarImageView: FC<LayoutAvatarImageViewProps> = props =>
|
||||
{
|
||||
const resetFigure = (_figure: string) =>
|
||||
{
|
||||
if(isDisposed.current) return;
|
||||
if(isDisposed.current || (requestIdRef.current !== requestId)) return;
|
||||
|
||||
const avatarImage = GetAvatarRenderManager().createAvatarImage(_figure, AvatarScaleType.LARGE, gender, { resetFigure: (figure: string) => resetFigure(figure), dispose: null, disposed: false });
|
||||
|
||||
@@ -74,7 +81,7 @@ export const LayoutAvatarImageView: FC<LayoutAvatarImageViewProps> = props =>
|
||||
|
||||
const imageUrl = avatarImage.processAsImageUrl(setType);
|
||||
|
||||
if(imageUrl && !isDisposed.current)
|
||||
if(imageUrl && !isDisposed.current && (requestIdRef.current === requestId))
|
||||
{
|
||||
if(!avatarImage.isPlaceholder())
|
||||
{
|
||||
|
||||
@@ -17,21 +17,29 @@ export const LayoutFurniImageView: FC<LayoutFurniImageViewProps> = props =>
|
||||
const { productType = 's', productClassId = -1, direction = 2, extraData = '', scale = 1, style = {}, ...rest } = props;
|
||||
const [ imageElement, setImageElement ] = useState<HTMLImageElement>(null);
|
||||
const isMounted = useRef(true);
|
||||
// Request id bumped by the effect on every prop change. The async
|
||||
// generateImage / imageReady callbacks capture it and only write
|
||||
// back if it still matches — prevents an older, slower fetch from
|
||||
// overwriting a newer one when props change in quick succession.
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
isMounted.current = true;
|
||||
|
||||
return () => { isMounted.current = false; };
|
||||
return () =>
|
||||
{
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateImage = useCallback(async (texture: any) =>
|
||||
const updateImage = useCallback(async (texture: any, requestId: number) =>
|
||||
{
|
||||
if(!texture) return;
|
||||
|
||||
const image = await TextureUtils.generateImage(texture);
|
||||
|
||||
if(image && isMounted.current) setImageElement(image);
|
||||
if(image && isMounted.current && (requestIdRef.current === requestId)) setImageElement(image);
|
||||
}, []);
|
||||
|
||||
const getStyle = useMemo(() =>
|
||||
@@ -59,12 +67,14 @@ export const LayoutFurniImageView: FC<LayoutFurniImageViewProps> = props =>
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const requestId = ++requestIdRef.current;
|
||||
|
||||
setImageElement(null);
|
||||
|
||||
let imageResult: ImageResult = null;
|
||||
|
||||
const listener: IGetImageListener = {
|
||||
imageReady: (result) => updateImage(result?.data),
|
||||
imageReady: (result) => updateImage(result?.data, requestId),
|
||||
imageFailed: null
|
||||
};
|
||||
|
||||
@@ -78,7 +88,7 @@ export const LayoutFurniImageView: FC<LayoutFurniImageViewProps> = props =>
|
||||
break;
|
||||
}
|
||||
|
||||
if(imageResult?.data) updateImage(imageResult.data);
|
||||
if(imageResult?.data) updateImage(imageResult.data, requestId);
|
||||
}, [ productType, productClassId, direction, extraData, updateImage ]);
|
||||
|
||||
return <Base classNames={ [ 'furni-image' ] } style={ getStyle } { ...rest } />;
|
||||
|
||||
@@ -9,11 +9,13 @@ interface LayoutMiniCameraViewProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const LayoutMiniCameraView: FC<LayoutMiniCameraViewProps> = props => {
|
||||
export const LayoutMiniCameraView: FC<LayoutMiniCameraViewProps> = props =>
|
||||
{
|
||||
const { roomId = -1, textureReceiver = null, onClose = null } = props;
|
||||
const elementRef = useRef<HTMLDivElement>();
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const getCameraBounds = () => {
|
||||
const getCameraBounds = () =>
|
||||
{
|
||||
if (!elementRef || !elementRef.current) return null;
|
||||
|
||||
const frameBounds = elementRef.current.getBoundingClientRect();
|
||||
@@ -26,7 +28,8 @@ export const LayoutMiniCameraView: FC<LayoutMiniCameraViewProps> = props => {
|
||||
);
|
||||
};
|
||||
|
||||
const takePicture = () => {
|
||||
const takePicture = () =>
|
||||
{
|
||||
PlaySound(SoundNames.CAMERA_SHUTTER);
|
||||
textureReceiver(GetRoomEngine().createTextureFromRoom(roomId, 1, getCameraBounds()));
|
||||
};
|
||||
|
||||
@@ -67,10 +67,12 @@ export const LayoutPetImageView: FC<LayoutPetImageViewProps> = props =>
|
||||
if(petTypeId === 16) petHeadOnly = false;
|
||||
|
||||
const imageResult = GetRoomEngine().getRoomObjectPetImage(petTypeId, petPaletteId, petColor1, new Vector3d((direction * 45)), 64, {
|
||||
imageReady: async (id, texture, image) =>
|
||||
imageReady: async (result) =>
|
||||
{
|
||||
if(isDisposed.current) return;
|
||||
|
||||
const { image, data: texture } = result;
|
||||
|
||||
if(image)
|
||||
{
|
||||
setPetUrl(image.src);
|
||||
@@ -85,9 +87,9 @@ export const LayoutPetImageView: FC<LayoutPetImageViewProps> = props =>
|
||||
setHeight(texture.height);
|
||||
}
|
||||
},
|
||||
imageFailed: (id) =>
|
||||
imageFailed: () =>
|
||||
{
|
||||
|
||||
// no-op
|
||||
}
|
||||
}, petHeadOnly, 0, petCustomParts, posture);
|
||||
|
||||
|
||||
@@ -21,7 +21,10 @@ export const LayoutRoomObjectImageView: FC<LayoutRoomObjectImageViewProps> = pro
|
||||
{
|
||||
isMounted.current = true;
|
||||
|
||||
return () => { isMounted.current = false; };
|
||||
return () =>
|
||||
{
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getStyle = useMemo(() =>
|
||||
@@ -50,13 +53,16 @@ export const LayoutRoomObjectImageView: FC<LayoutRoomObjectImageViewProps> = pro
|
||||
useEffect(() =>
|
||||
{
|
||||
const imageResult = GetRoomEngine().getRoomObjectImage(roomId, objectId, category, new Vector3d(direction * 45), 64, {
|
||||
imageReady: async (id, texture, image) =>
|
||||
imageReady: async (result) =>
|
||||
{
|
||||
const img = await TextureUtils.generateImage(texture);
|
||||
const img = await TextureUtils.generateImage(result.data);
|
||||
|
||||
if(img && isMounted.current) setImageElement(img);
|
||||
},
|
||||
imageFailed: null
|
||||
imageFailed: () =>
|
||||
{
|
||||
// no-op
|
||||
}
|
||||
});
|
||||
|
||||
if(!imageResult) return;
|
||||
|
||||
@@ -7,7 +7,7 @@ export const LayoutRoomPreviewerView: FC<{
|
||||
}> = props =>
|
||||
{
|
||||
const { roomPreviewer = null, height = 0 } = props;
|
||||
const elementRef = useRef<HTMLDivElement>();
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const onClick = (event: MouseEvent<HTMLDivElement>) =>
|
||||
{
|
||||
|
||||
@@ -1 +1 @@
|
||||
export type ColorVariantType = 'primary' | 'success' | 'danger' | 'secondary' | 'link' | 'black' | 'white' | 'dark' | 'warning' | 'muted' | 'light' | 'gray';
|
||||
export type ColorVariantType = 'primary' | 'success' | 'danger' | 'secondary' | 'link' | 'black' | 'white' | 'dark' | 'warning' | 'muted' | 'light' | 'gray' | 'outline-primary' | 'outline-secondary' | 'outline-success' | 'outline-danger' | 'outline-warning';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AddLinkEventTracker, GetCommunication, GetRoomSessionManager, HabboWebTools, ILinkEventTracker, RemoveLinkEventTracker, RoomSessionEvent } from '@nitrots/nitro-renderer';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { useNitroEvent } from '../hooks';
|
||||
import { useNitroEventReducer } from '../hooks';
|
||||
import { AchievementsView } from './achievements/AchievementsView';
|
||||
import { AvatarEditorView } from './avatar-editor';
|
||||
import { BadgeCreatorView } from './badge-creator';
|
||||
@@ -44,11 +44,33 @@ import { WiredCreatorToolsView } from './wired-tools/WiredCreatorToolsView';
|
||||
export const MainView: FC<{}> = props =>
|
||||
{
|
||||
const [ isReady, setIsReady ] = useState(false);
|
||||
const [ landingViewVisible, setLandingViewVisible ] = useState(true);
|
||||
const [ localizationVersion, setLocalizationVersion ] = useState(0);
|
||||
|
||||
useNitroEvent<RoomSessionEvent>(RoomSessionEvent.CREATED, event => setLandingViewVisible(false));
|
||||
useNitroEvent<RoomSessionEvent>(RoomSessionEvent.ENDED, event => setLandingViewVisible(event.openLandingView));
|
||||
// CREATED and ENDED can arrive out of order under flaky reconnects.
|
||||
// Treating them as two independent setters left landingViewVisible
|
||||
// contradicting the actual session state (stuck open in-room or
|
||||
// stuck closed at the hotel view). The reducer carries the active
|
||||
// session's roomId so a stale ENDED for a previous session is
|
||||
// ignored — only an ENDED matching the tracked session (or when
|
||||
// no session is active) is honored.
|
||||
const { landingViewVisible } = useNitroEventReducer<{ sessionId: number | null; landingViewVisible: boolean }, RoomSessionEvent>(
|
||||
[ RoomSessionEvent.CREATED, RoomSessionEvent.ENDED ],
|
||||
(state, event) =>
|
||||
{
|
||||
if(event.type === RoomSessionEvent.CREATED)
|
||||
{
|
||||
return { sessionId: event.session.roomId, landingViewVisible: false };
|
||||
}
|
||||
|
||||
if((state.sessionId !== null) && (event.session.roomId !== state.sessionId))
|
||||
{
|
||||
return state;
|
||||
}
|
||||
|
||||
return { sessionId: null, landingViewVisible: event.openLandingView };
|
||||
},
|
||||
{ sessionId: null, landingViewVisible: true }
|
||||
);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
@@ -132,7 +154,7 @@ export const MainView: FC<{}> = props =>
|
||||
<AvatarEffectsView />
|
||||
<AchievementsView />
|
||||
<NavigatorView />
|
||||
<NitrobubbleHiddenView />
|
||||
<NitrobubbleHiddenView />
|
||||
<InventoryView />
|
||||
<CatalogView />
|
||||
<FriendsView />
|
||||
|
||||
@@ -9,10 +9,10 @@ interface AdsenseConfig {
|
||||
fullWidthResponsive?: boolean;
|
||||
}
|
||||
|
||||
const ADSENSE_SCRIPT_ID = 'google-adsense-script';
|
||||
|
||||
const parsePublisherIdFromAdsTxt = (text: string): string | null => {
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const parsePublisherIdFromAdsTxt = (text: string): string | null =>
|
||||
{
|
||||
for (const rawLine of text.split(/\r?\n/))
|
||||
{
|
||||
const line = rawLine.split('#')[0].trim();
|
||||
if (!line) continue;
|
||||
const parts = line.split(',').map(part => part.trim());
|
||||
@@ -24,19 +24,8 @@ const parsePublisherIdFromAdsTxt = (text: string): string | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const ensureAdsenseScript = (publisherId: string): void => {
|
||||
if (typeof document === 'undefined') return;
|
||||
if (document.getElementById(ADSENSE_SCRIPT_ID)) return;
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.id = ADSENSE_SCRIPT_ID;
|
||||
script.async = true;
|
||||
script.crossOrigin = 'anonymous';
|
||||
script.src = `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-${ publisherId }`;
|
||||
document.head.appendChild(script);
|
||||
};
|
||||
|
||||
export const GoogleAdsView: FC<{}> = () => {
|
||||
export const GoogleAdsView: FC<{}> = () =>
|
||||
{
|
||||
const adsEnabled = GetConfigurationValue<boolean>('show.google.ads', false);
|
||||
const [ isOpen, setIsOpen ] = useState(false);
|
||||
const [ publisherId, setPublisherId ] = useState<string | null>(null);
|
||||
@@ -46,7 +35,8 @@ export const GoogleAdsView: FC<{}> = () => {
|
||||
const pushedRef = useRef(false);
|
||||
const autoOpenedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
useEffect(() =>
|
||||
{
|
||||
if (!adsEnabled) return;
|
||||
const handler = () => setIsOpen(prev => !prev);
|
||||
window.addEventListener('ads:toggle', handler);
|
||||
@@ -56,7 +46,8 @@ export const GoogleAdsView: FC<{}> = () => {
|
||||
// Auto-open once on initial mount (the login / landing stage).
|
||||
// Subsequent toggles are driven by the "ads:toggle" window event
|
||||
// (e.g. the Show Ad button in NitroSystemAlertView).
|
||||
useEffect(() => {
|
||||
useEffect(() =>
|
||||
{
|
||||
if (!adsEnabled) return;
|
||||
if (autoOpenedRef.current) return;
|
||||
autoOpenedRef.current = true;
|
||||
@@ -64,11 +55,14 @@ export const GoogleAdsView: FC<{}> = () => {
|
||||
return () => clearTimeout(t);
|
||||
}, [ adsEnabled ]);
|
||||
|
||||
useEffect(() => {
|
||||
useEffect(() =>
|
||||
{
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const [ adsTxtRes, configRes ] = await Promise.all([
|
||||
fetch('/ads.txt', { cache: 'no-cache' }),
|
||||
fetch(configFileUrl('adsense.json', true), { cache: 'no-cache' })
|
||||
@@ -87,44 +81,54 @@ export const GoogleAdsView: FC<{}> = () => {
|
||||
if (cancelled) return;
|
||||
setPublisherId(pubId);
|
||||
setConfig(cfg);
|
||||
} catch (err) {
|
||||
}
|
||||
catch (err)
|
||||
{
|
||||
if (!cancelled) setLoadError((err as Error).message);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
return () =>
|
||||
{
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !publisherId || !config) return;
|
||||
ensureAdsenseScript(publisherId);
|
||||
}, [ isOpen, publisherId, config ]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
useEffect(() =>
|
||||
{
|
||||
if (!isOpen)
|
||||
{
|
||||
pushedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!insRef.current || pushedRef.current) return;
|
||||
if (!publisherId || !config?.slot) return;
|
||||
|
||||
const tryPush = () => {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const tryPush = () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
const w = window as any;
|
||||
w.adsbygoogle = w.adsbygoogle || [];
|
||||
w.adsbygoogle.push({});
|
||||
pushedRef.current = true;
|
||||
} catch {
|
||||
}
|
||||
catch
|
||||
{
|
||||
// AdSense script may not be ready yet; retry once
|
||||
setTimeout(() => {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setTimeout(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
const w = window as any;
|
||||
w.adsbygoogle = w.adsbygoogle || [];
|
||||
w.adsbygoogle.push({});
|
||||
pushedRef.current = true;
|
||||
} catch { /* give up */ }
|
||||
}
|
||||
catch
|
||||
{ /* give up */ }
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
@@ -138,6 +142,11 @@ export const GoogleAdsView: FC<{}> = () => {
|
||||
|
||||
return (
|
||||
<NitroCardView className="nitro-google-ads" uniqueKey="google-ads" theme="primary">
|
||||
{ publisherId &&
|
||||
<script
|
||||
async
|
||||
crossOrigin="anonymous"
|
||||
src={ `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-${ publisherId }` } /> }
|
||||
<NitroCardHeaderView headerText="Sponsored" onCloseClick={ () => setIsOpen(false) } />
|
||||
<NitroCardContentView>
|
||||
<div className="flex items-center justify-center w-[300px] h-[250px] bg-white">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DetailedHTMLProps, HTMLAttributes, PropsWithChildren, forwardRef } from 'react';
|
||||
import { DetailedHTMLProps, HTMLAttributes, PropsWithChildren, Ref } from 'react';
|
||||
import { classNames } from '../../layout';
|
||||
|
||||
import arrowLeftIcon from '../../assets/images/avatareditor/arrow-left-icon.png';
|
||||
@@ -55,13 +55,14 @@ const ICON_MAP: Record<string, { normal: string; selected?: string }> = {
|
||||
'wa': { normal: waIcon, selected: waSelectedIcon },
|
||||
};
|
||||
|
||||
export const AvatarEditorIcon = forwardRef<HTMLDivElement, PropsWithChildren<{
|
||||
type AvatarEditorIconProps = PropsWithChildren<{
|
||||
icon: string;
|
||||
selected?: boolean;
|
||||
}> & DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>>((props, ref) =>
|
||||
{
|
||||
const { icon = null, selected = false, className = null, children, ...rest } = props;
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
}> & DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>;
|
||||
|
||||
export const AvatarEditorIcon = ({ ref, icon = null, selected = false, className = null, children, ...rest }: AvatarEditorIconProps) =>
|
||||
{
|
||||
const iconEntry = icon ? ICON_MAP[icon] : null;
|
||||
|
||||
if(!iconEntry) return null;
|
||||
@@ -77,6 +78,4 @@ export const AvatarEditorIcon = forwardRef<HTMLDivElement, PropsWithChildren<{
|
||||
{ children }
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
AvatarEditorIcon.displayName = 'AvatarEditorIcon';
|
||||
};
|
||||
|
||||
@@ -23,7 +23,10 @@ const findNearestColor = (hex: string, colors: IPartColor[]): IPartColor | null
|
||||
const cb = color.rgb & 0xFF;
|
||||
const dist = (r - cr) ** 2 + (g - cg) ** 2 + (b - cb) ** 2;
|
||||
|
||||
if(dist < minDist) { minDist = dist; nearest = color; }
|
||||
if(dist < minDist)
|
||||
{
|
||||
minDist = dist; nearest = color;
|
||||
}
|
||||
}
|
||||
|
||||
return nearest;
|
||||
@@ -40,7 +43,10 @@ export const AvatarEditorAdvancedColorView: FC<{
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
return () => { if(debounceRef.current) clearTimeout(debounceRef.current); };
|
||||
return () =>
|
||||
{
|
||||
if(debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const selectedColor = useMemo(() =>
|
||||
@@ -52,7 +58,7 @@ export const AvatarEditorAdvancedColorView: FC<{
|
||||
|
||||
const hexColor = useMemo(() =>
|
||||
ColorUtils.makeColorNumberHex((selectedColor?.rgb ?? 0) & 0xFFFFFF),
|
||||
[ selectedColor ]);
|
||||
[ selectedColor ]);
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AddLinkEventTracker, AvatarDirectionAngle, AvatarEffectActivatedComposer, GetConfiguration, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||
import { AddLinkEventTracker, AvatarDirectionAngle, AvatarEffectActivatedComposer, GetConfiguration, GetSessionDataManager, ILinkEventTracker, loadGamedata, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||
import { ChangeEvent, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { FaChevronLeft, FaChevronRight, FaSearch } from 'react-icons/fa';
|
||||
import { LocalizeText, SendMessageComposer } from '../../api';
|
||||
@@ -36,8 +36,8 @@ export const AvatarEffectsView: FC<{}> = () =>
|
||||
|
||||
switch(parts[1])
|
||||
{
|
||||
case 'show': setIsVisible(true); return;
|
||||
case 'hide': setIsVisible(false); return;
|
||||
case 'show': setIsVisible(true); return;
|
||||
case 'hide': setIsVisible(false); return;
|
||||
case 'toggle': setIsVisible(prev => !prev); return;
|
||||
}
|
||||
},
|
||||
@@ -65,9 +65,11 @@ export const AvatarEffectsView: FC<{}> = () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const response = await fetch(url);
|
||||
if(!response.ok) throw new Error(`HTTP ${ response.status }`);
|
||||
const json = await response.json();
|
||||
// The effectmap is served either as a single JSON file or as a
|
||||
// tiered directory with core/custom/seasonal manifests using
|
||||
// JSON5 syntax (// comments allowed). loadGamedata picks the
|
||||
// right mode for us and merges tiers.
|
||||
const json = await loadGamedata<{ effects?: EffectMapEntry[] }>(url);
|
||||
if(cancelled) return;
|
||||
|
||||
const list: EffectMapEntry[] = Array.isArray(json?.effects)
|
||||
@@ -83,7 +85,10 @@ export const AvatarEffectsView: FC<{}> = () =>
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
return () =>
|
||||
{
|
||||
cancelled = true;
|
||||
};
|
||||
}, [ isVisible, effects.length, loadError ]);
|
||||
|
||||
const session = GetSessionDataManager();
|
||||
@@ -108,6 +113,13 @@ export const AvatarEffectsView: FC<{}> = () =>
|
||||
setIsVisible(false);
|
||||
}, [ selectedId ]);
|
||||
|
||||
const removeCurrentEffect = useCallback(() =>
|
||||
{
|
||||
SendMessageComposer(new AvatarEffectActivatedComposer(0));
|
||||
setSelectedId(0);
|
||||
setIsVisible(false);
|
||||
}, []);
|
||||
|
||||
const onClose = useCallback(() => setIsVisible(false), []);
|
||||
|
||||
const filteredEffects = useMemo(() =>
|
||||
@@ -191,9 +203,14 @@ export const AvatarEffectsView: FC<{}> = () =>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<Button variant="success" disabled={ !selectedId } onClick={ applySelectedEffect } className="w-full mt-2">
|
||||
{ LocalizeText('inventory.effects.activate') || 'Use' }
|
||||
</Button>
|
||||
<div className="flex gap-1 mt-2">
|
||||
<Button variant="success" disabled={ !selectedId } onClick={ applySelectedEffect } className="flex-1">
|
||||
{ LocalizeText('inventory.effects.activate') || 'Use effect' }
|
||||
</Button>
|
||||
<Button variant="danger" onClick={ removeCurrentEffect } className="flex-1">
|
||||
{ LocalizeText('inventory.effects.remove') || 'Remove effect' }
|
||||
</Button>
|
||||
</div>
|
||||
</Column>
|
||||
<Column overflow="hidden" className="flex-1 min-h-0">
|
||||
<div className="relative">
|
||||
|
||||
@@ -18,12 +18,42 @@ interface BackgroundsViewProps {
|
||||
setSelectedOverlay: Dispatch<SetStateAction<number>>;
|
||||
selectedCardBackground: number;
|
||||
setSelectedCardBackground: Dispatch<SetStateAction<number>>;
|
||||
selectedBorder: number;
|
||||
setSelectedBorder: Dispatch<SetStateAction<number>>;
|
||||
}
|
||||
|
||||
const TABS = ['backgrounds', 'stands', 'overlays', 'cards'] as const;
|
||||
const TABS = ['backgrounds', 'stands', 'overlays', 'cards', 'borders'] as const;
|
||||
type TabType = typeof TABS[number];
|
||||
|
||||
type RemoteData = Partial<Record<'backgrounds.data' | 'stands.data' | 'overlays.data' | 'cards.data', any[]>>;
|
||||
type RemoteData = Partial<Record<'backgrounds.data' | 'stands.data' | 'overlays.data' | 'cards.data' | 'borders.data', any[]>>;
|
||||
|
||||
// Module-scoped cache so repeated mounts don't refetch the same JSON.
|
||||
// Not a Promise — we deliberately don't expose anything that could be
|
||||
// passed to React's `use()` hook. Suspending here unmounts the parent
|
||||
// room tree (no <Suspense> boundary upstream), which orphans the Pixi
|
||||
// canvas and leaves the room rendered as a black square until another
|
||||
// state change forces a re-render.
|
||||
let cachedBackgroundsData: RemoteData | null = null;
|
||||
let inflightBackgroundsFetch: Promise<RemoteData | null> | null = null;
|
||||
|
||||
const loadBackgroundsData = (): Promise<RemoteData | null> =>
|
||||
{
|
||||
if(cachedBackgroundsData) return Promise.resolve(cachedBackgroundsData);
|
||||
if(inflightBackgroundsFetch) return inflightBackgroundsFetch;
|
||||
|
||||
inflightBackgroundsFetch = fetch(configFileUrl('infostand_backgrounds.json'), { credentials: 'omit' })
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(json =>
|
||||
{
|
||||
const result = (json && typeof json === 'object') ? json as RemoteData : null;
|
||||
cachedBackgroundsData = result;
|
||||
return result;
|
||||
})
|
||||
.catch(() => null)
|
||||
.finally(() => { inflightBackgroundsFetch = null; });
|
||||
|
||||
return inflightBackgroundsFetch;
|
||||
};
|
||||
|
||||
export const BackgroundsView: FC<BackgroundsViewProps> = ({
|
||||
setIsVisible,
|
||||
@@ -34,28 +64,35 @@ export const BackgroundsView: FC<BackgroundsViewProps> = ({
|
||||
selectedOverlay,
|
||||
setSelectedOverlay,
|
||||
selectedCardBackground,
|
||||
setSelectedCardBackground
|
||||
}) => {
|
||||
setSelectedCardBackground,
|
||||
selectedBorder,
|
||||
setSelectedBorder
|
||||
}) =>
|
||||
{
|
||||
const [activeTab, setActiveTab] = useState<TabType>('backgrounds');
|
||||
const [remoteData, setRemoteData] = useState<RemoteData | null>(null);
|
||||
const [remoteData, setRemoteData] = useState<RemoteData | null>(cachedBackgroundsData);
|
||||
const { roomSession } = useRoom();
|
||||
|
||||
useEffect(() => {
|
||||
useEffect(() =>
|
||||
{
|
||||
if(remoteData) return;
|
||||
let cancelled = false;
|
||||
fetch(configFileUrl('infostand_backgrounds.json'), { credentials: 'omit' })
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(json => { if(!cancelled && json && typeof json === 'object') setRemoteData(json as RemoteData); })
|
||||
.catch(() => {});
|
||||
loadBackgroundsData().then(data =>
|
||||
{
|
||||
if(!cancelled && data) setRemoteData(data);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
}, [remoteData]);
|
||||
|
||||
const processData = useCallback((configData: any[], idField: string): ItemData[] => {
|
||||
const processData = useCallback((configData: any[], idField: string): ItemData[] =>
|
||||
{
|
||||
if (!configData?.length) return [];
|
||||
|
||||
return configData.map(item => ({ id: typeof item === 'number' ? item : item[idField] }));
|
||||
}, []);
|
||||
|
||||
const readData = useCallback((key: 'backgrounds.data' | 'stands.data' | 'overlays.data' | 'cards.data'): any[] => {
|
||||
const readData = useCallback((key: 'backgrounds.data' | 'stands.data' | 'overlays.data' | 'cards.data' | 'borders.data'): any[] =>
|
||||
{
|
||||
const fromRemote = remoteData?.[key];
|
||||
if(Array.isArray(fromRemote)) return fromRemote;
|
||||
return GetOptionalConfigurationValue<any[]>(key, []) || [];
|
||||
@@ -65,20 +102,28 @@ export const BackgroundsView: FC<BackgroundsViewProps> = ({
|
||||
backgrounds: processData(readData('backgrounds.data'), 'backgroundId'),
|
||||
stands: processData(readData('stands.data'), 'standId'),
|
||||
overlays: processData(readData('overlays.data'), 'overlayId'),
|
||||
cards: processData(readData('cards.data').length ? readData('cards.data') : readData('backgrounds.data'), 'backgroundId')
|
||||
cards: processData(readData('cards.data').length ? readData('cards.data') : readData('backgrounds.data'), 'backgroundId'),
|
||||
borders: processData(readData('borders.data'), 'borderId')
|
||||
}), [processData, readData]);
|
||||
|
||||
const handleSelection = useCallback((id: number) => {
|
||||
const handleSelection = useCallback((id: number) =>
|
||||
{
|
||||
if (!roomSession) return;
|
||||
|
||||
const setters = { backgrounds: setSelectedBackground, stands: setSelectedStand, overlays: setSelectedOverlay, cards: setSelectedCardBackground };
|
||||
const setters = { backgrounds: setSelectedBackground, stands: setSelectedStand, overlays: setSelectedOverlay, cards: setSelectedCardBackground, borders: setSelectedBorder };
|
||||
|
||||
const currentValues = { backgrounds: selectedBackground, stands: selectedStand, overlays: selectedOverlay, cards: selectedCardBackground };
|
||||
const currentValues = { backgrounds: selectedBackground, stands: selectedStand, overlays: selectedOverlay, cards: selectedCardBackground, borders: selectedBorder };
|
||||
|
||||
setters[activeTab](id);
|
||||
const newValues = { ...currentValues, [activeTab]: id };
|
||||
roomSession.sendBackgroundMessage( newValues.backgrounds, newValues.stands, newValues.overlays, newValues.cards );
|
||||
}, [activeTab, roomSession, selectedBackground, selectedStand, selectedOverlay, selectedCardBackground, setSelectedBackground, setSelectedStand, setSelectedOverlay, setSelectedCardBackground]);
|
||||
roomSession.sendBackgroundMessage( newValues.backgrounds, newValues.stands, newValues.overlays, newValues.cards, newValues.borders );
|
||||
}, [activeTab, roomSession, selectedBackground, selectedStand, selectedOverlay, selectedCardBackground, selectedBorder, setSelectedBackground, setSelectedStand, setSelectedOverlay, setSelectedCardBackground, setSelectedBorder]);
|
||||
|
||||
const itemTypeFor = (tab: TabType): string => {
|
||||
if(tab === 'cards') return 'card-background';
|
||||
if(tab === 'borders') return 'border';
|
||||
return tab.slice(0, -1);
|
||||
};
|
||||
|
||||
const renderItem = useCallback((item: ItemData, type: string) => (
|
||||
<Flex
|
||||
@@ -89,7 +134,11 @@ export const BackgroundsView: FC<BackgroundsViewProps> = ({
|
||||
>
|
||||
<Base
|
||||
className={`profile-${type} ${type}-${item.id}`}
|
||||
style={type === 'card-background' ? { width: 60, height: 80, borderRadius: 4 } : undefined}
|
||||
style={
|
||||
type === 'card-background' ? { width: 60, height: 80, borderRadius: 4 }
|
||||
: type === 'border' ? { width: 60, height: 76, backgroundSize: 'contain', backgroundRepeat: 'no-repeat', backgroundPosition: 'center' }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Flex>
|
||||
), [handleSelection]);
|
||||
@@ -111,7 +160,7 @@ export const BackgroundsView: FC<BackgroundsViewProps> = ({
|
||||
<NitroCardContentView gap={1}>
|
||||
<Text bold center>Select an Option</Text>
|
||||
<Grid gap={1} columnCount={7} overflow="auto">
|
||||
{allData[activeTab].map(item => renderItem(item, activeTab === 'cards' ? 'card-background' : activeTab.slice(0, -1)))}
|
||||
{allData[activeTab].map(item => renderItem(item, itemTypeFor(activeTab)))}
|
||||
</Grid>
|
||||
</NitroCardContentView>
|
||||
</NitroCardView>
|
||||
|
||||
@@ -12,7 +12,8 @@ const t = (key: string, fallback: string, params?: string[], replacements?: stri
|
||||
const value = LocalizeText(key, params ?? null, replacements ?? null);
|
||||
if(value && value !== key) return value;
|
||||
}
|
||||
catch {}
|
||||
catch
|
||||
{}
|
||||
|
||||
if(!params || !replacements) return fallback;
|
||||
let out = fallback;
|
||||
@@ -38,8 +39,8 @@ const PALETTE: number[] = [
|
||||
const currencyName = (type: number): string =>
|
||||
{
|
||||
if(type === -1) return 'credits';
|
||||
if(type === 0) return 'duckets';
|
||||
if(type === 5) return 'diamonds';
|
||||
if(type === 0) return 'duckets';
|
||||
if(type === 5) return 'diamonds';
|
||||
return `currency #${ type }`;
|
||||
};
|
||||
|
||||
@@ -58,14 +59,14 @@ const floodFill = (grid: Uint32Array, w: number, h: number, startX: number, star
|
||||
const stack: number[] = [ startIdx ];
|
||||
while(stack.length)
|
||||
{
|
||||
const idx = stack.pop() as number;
|
||||
const idx = stack.pop();
|
||||
if(next[idx] !== target) continue;
|
||||
next[idx] = replacement;
|
||||
const x = idx % w;
|
||||
const y = (idx - x) / w;
|
||||
if(x > 0) stack.push(idx - 1);
|
||||
if(x > 0) stack.push(idx - 1);
|
||||
if(x < w - 1) stack.push(idx + 1);
|
||||
if(y > 0) stack.push(idx - w);
|
||||
if(y > 0) stack.push(idx - w);
|
||||
if(y < h - 1) stack.push(idx + w);
|
||||
}
|
||||
return next;
|
||||
@@ -119,7 +120,7 @@ const gridToPngBase64 = async (grid: Uint32Array): Promise<{ b64: string; bytes:
|
||||
{
|
||||
const argb = grid[i];
|
||||
const o = i * 4;
|
||||
image.data[o] = (argb >>> 16) & 0xff;
|
||||
image.data[o] = (argb >>> 16) & 0xff;
|
||||
image.data[o + 1] = (argb >>> 8) & 0xff;
|
||||
image.data[o + 2] = argb & 0xff;
|
||||
image.data[o + 3] = (argb >>> 24) & 0xff;
|
||||
@@ -157,12 +158,18 @@ const loadGridFromUrl = (url: string): Promise<Uint32Array> =>
|
||||
{
|
||||
const o = i * 4;
|
||||
const a = data[o + 3];
|
||||
if(a === 0) { grid[i] = 0; continue; }
|
||||
if(a === 0)
|
||||
{
|
||||
grid[i] = 0; continue;
|
||||
}
|
||||
grid[i] = ((a & 0xff) << 24) | ((data[o] & 0xff) << 16) | ((data[o + 1] & 0xff) << 8) | (data[o + 2] & 0xff);
|
||||
}
|
||||
resolve(grid);
|
||||
}
|
||||
catch(err) { reject(err); }
|
||||
catch(err)
|
||||
{
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
image.onerror = () => reject(new Error('Could not load badge image (CORS?).'));
|
||||
image.src = url + (url.includes('?') ? '&' : '?') + 't=' + Date.now();
|
||||
@@ -216,8 +223,8 @@ export const BadgeCreatorView: FC<{}> = () =>
|
||||
if(parts.length < 2) return;
|
||||
switch(parts[1])
|
||||
{
|
||||
case 'show': setIsVisible(true); return;
|
||||
case 'hide': setIsVisible(false); return;
|
||||
case 'show': setIsVisible(true); return;
|
||||
case 'hide': setIsVisible(false); return;
|
||||
case 'toggle': setIsVisible(v => !v); return;
|
||||
case 'edit':
|
||||
if(!parts[2]) return;
|
||||
@@ -232,7 +239,13 @@ export const BadgeCreatorView: FC<{}> = () =>
|
||||
return () => RemoveLinkEventTracker(tracker);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { if(isVisible) { refresh(); ensureCustomBadgeTexts(); } }, [ isVisible, refresh ]);
|
||||
useEffect(() =>
|
||||
{
|
||||
if(isVisible)
|
||||
{
|
||||
refresh(); ensureCustomBadgeTexts();
|
||||
}
|
||||
}, [ isVisible, refresh ]);
|
||||
|
||||
const resetEditor = useCallback(() =>
|
||||
{
|
||||
@@ -316,9 +329,9 @@ export const BadgeCreatorView: FC<{}> = () =>
|
||||
{
|
||||
const v = grid[i];
|
||||
const o = i * 4;
|
||||
buffer[o] = (v >>> 16) & 0xff;
|
||||
buffer[o + 1] = (v >>> 8) & 0xff;
|
||||
buffer[o + 2] = v & 0xff;
|
||||
buffer[o] = (v >>> 16) & 0xff;
|
||||
buffer[o + 1] = (v >>> 8) & 0xff;
|
||||
buffer[o + 2] = v & 0xff;
|
||||
buffer[o + 3] = (v >>> 24) & 0xff;
|
||||
}
|
||||
ctx.putImageData(image, 0, 0);
|
||||
@@ -365,7 +378,10 @@ export const BadgeCreatorView: FC<{}> = () =>
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const stopDrag = () => { isDraggingRef.current = false; };
|
||||
const stopDrag = () =>
|
||||
{
|
||||
isDraggingRef.current = false;
|
||||
};
|
||||
window.addEventListener('mouseup', stopDrag);
|
||||
return () => window.removeEventListener('mouseup', stopDrag);
|
||||
}, []);
|
||||
@@ -385,7 +401,10 @@ export const BadgeCreatorView: FC<{}> = () =>
|
||||
const handleSave = useCallback(async () =>
|
||||
{
|
||||
if(submitting) return;
|
||||
if(isEmpty) { setError(t('badgecreator.error.empty', 'Draw something first.')); return; }
|
||||
if(isEmpty)
|
||||
{
|
||||
setError(t('badgecreator.error.empty', 'Draw something first.')); return;
|
||||
}
|
||||
if(!editingBadgeId && !canCreateMore)
|
||||
{
|
||||
setError(t('badgecreator.error.limit', 'You already have %max% custom badges.', [ 'max' ], [ String(maxBadges) ]));
|
||||
@@ -506,7 +525,10 @@ export const BadgeCreatorView: FC<{}> = () =>
|
||||
<button
|
||||
key={ idx }
|
||||
type="button"
|
||||
onClick={ () => { setSelectedColor(color); setTool('paint'); } }
|
||||
onClick={ () =>
|
||||
{
|
||||
setSelectedColor(color); setTool('paint');
|
||||
} }
|
||||
title={ isTransparent ? 'Transparent' : argbToCss(color) }
|
||||
style={ {
|
||||
width: 22,
|
||||
|
||||
@@ -19,7 +19,7 @@ export const CameraWidgetCaptureView: FC<CameraWidgetCaptureViewProps> = props =
|
||||
const { onClose = null, onEdit = null, onDelete = null } = props;
|
||||
const { cameraRoll = null, setCameraRoll = null, selectedPictureIndex = -1, setSelectedPictureIndex = null } = useCamera();
|
||||
const { simpleAlert = null } = useNotification();
|
||||
const elementRef = useRef<HTMLDivElement>();
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectedPicture = ((selectedPictureIndex > -1) ? cameraRoll[selectedPictureIndex] : null);
|
||||
|
||||
|
||||
@@ -10,40 +10,46 @@ export interface CameraWidgetShowPhotoViewProps {
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const CameraWidgetShowPhotoView: FC<CameraWidgetShowPhotoViewProps> = props => {
|
||||
export const CameraWidgetShowPhotoView: FC<CameraWidgetShowPhotoViewProps> = props =>
|
||||
{
|
||||
const { currentIndex = -1, currentPhotos = null, onClick = null } = props;
|
||||
const [imageIndex, setImageIndex] = useState(0);
|
||||
|
||||
const currentImage = currentPhotos && currentPhotos.length ? currentPhotos[imageIndex] : null;
|
||||
|
||||
const next = () => {
|
||||
setImageIndex(prevValue => {
|
||||
const next = () =>
|
||||
{
|
||||
setImageIndex(prevValue =>
|
||||
{
|
||||
let newIndex = prevValue + 1;
|
||||
if (newIndex >= currentPhotos.length) newIndex = 0;
|
||||
return newIndex;
|
||||
});
|
||||
};
|
||||
|
||||
const previous = () => {
|
||||
setImageIndex(prevValue => {
|
||||
const previous = () =>
|
||||
{
|
||||
setImageIndex(prevValue =>
|
||||
{
|
||||
let newIndex = prevValue - 1;
|
||||
if (newIndex < 0) newIndex = currentPhotos.length - 1;
|
||||
return newIndex;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
useEffect(() =>
|
||||
{
|
||||
setImageIndex(currentIndex);
|
||||
}, [currentIndex]);
|
||||
|
||||
if (!currentImage) return null;
|
||||
|
||||
const getUserData = (roomId: number, objectId: number, type: string): number | string =>
|
||||
|
||||
const getUserData = (roomId: number, objectId: number, type: string): number | string =>
|
||||
{
|
||||
const roomObject = GetRoomEngine().getRoomObject(roomId, objectId, RoomObjectCategory.WALL);
|
||||
if (!roomObject) return;
|
||||
return type == 'username' ? roomObject.model.getValue<number>(RoomObjectVariable.FURNITURE_OWNER_NAME) : roomObject.model.getValue<number>(RoomObjectVariable.FURNITURE_OWNER_ID);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
@@ -53,13 +59,13 @@ export const CameraWidgetShowPhotoView: FC<CameraWidgetShowPhotoViewProps> = pro
|
||||
{currentImage.m && currentImage.m.length && <Text center>{currentImage.m}</Text>}
|
||||
<div className="flex items-center center justify-between">
|
||||
<Text>{currentImage.n || ''}</Text>
|
||||
<Text onClick={() => GetUserProfile(Number(getUserData(currentImage.s, Number(currentImage.u), 'id')))}> { getUserData(currentImage.s, Number(currentImage.u), 'username') } </Text>
|
||||
<Text className="cursor-pointer" onClick={() => GetUserProfile(currentImage.oi)}>{currentImage.o}</Text>
|
||||
<Text>{new Date(currentImage.t * 1000).toLocaleDateString()}</Text>
|
||||
<Text onClick={() => GetUserProfile(Number(getUserData(currentImage.s, Number(currentImage.u), 'id')))}> { getUserData(currentImage.s, Number(currentImage.u), 'username') } </Text>
|
||||
<Text className="cursor-pointer" onClick={() => GetUserProfile(currentImage.oi)}>{currentImage.o}</Text>
|
||||
<Text>{new Date(currentImage.t * 1000).toLocaleDateString()}</Text>
|
||||
</div>
|
||||
{currentPhotos.length > 1 && (
|
||||
<Flex className="picture-preview-buttons">
|
||||
<FaArrowLeft onClick={previous} />
|
||||
<FaArrowLeft onClick={previous} />
|
||||
<FaArrowRight className="cursor-pointer"onClick={next} />
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
@@ -16,7 +16,8 @@ export interface CameraWidgetEditorViewProps {
|
||||
|
||||
const TABS: string[] = [ CameraEditorTabs.COLORMATRIX, CameraEditorTabs.COMPOSITE ];
|
||||
|
||||
export const CameraWidgetEditorView: FC<CameraWidgetEditorViewProps> = props => {
|
||||
export const CameraWidgetEditorView: FC<CameraWidgetEditorViewProps> = props =>
|
||||
{
|
||||
const { picture = null, availableEffects = null, myLevel = 1, onClose = null, onCancel = null, onCheckout = null } = props;
|
||||
const [ currentTab, setCurrentTab ] = useState(TABS[0]);
|
||||
const [ selectedEffectName, setSelectedEffectName ] = useState<string>(null);
|
||||
@@ -35,37 +36,45 @@ export const CameraWidgetEditorView: FC<CameraWidgetEditorViewProps> = props =>
|
||||
img.src = picture.imageUrl;
|
||||
}, [ picture ]);
|
||||
|
||||
const getColorMatrixEffects = useMemo(() => {
|
||||
const getColorMatrixEffects = useMemo(() =>
|
||||
{
|
||||
return availableEffects.filter(effect => effect.colorMatrix);
|
||||
}, [ availableEffects ]);
|
||||
|
||||
const getCompositeEffects = useMemo(() => {
|
||||
const getCompositeEffects = useMemo(() =>
|
||||
{
|
||||
return availableEffects.filter(effect => effect.texture);
|
||||
}, [ availableEffects ]);
|
||||
|
||||
const getEffectList = useCallback(() => {
|
||||
const getEffectList = useCallback(() =>
|
||||
{
|
||||
return currentTab === CameraEditorTabs.COLORMATRIX ? getColorMatrixEffects : getCompositeEffects;
|
||||
}, [ currentTab, getColorMatrixEffects, getCompositeEffects ]);
|
||||
|
||||
const getSelectedEffectIndex = useCallback((name: string) => {
|
||||
const getSelectedEffectIndex = useCallback((name: string) =>
|
||||
{
|
||||
if (!name || !name.length || !selectedEffects || !selectedEffects.length) return -1;
|
||||
return selectedEffects.findIndex(effect => effect.effect.name === name);
|
||||
}, [ selectedEffects ]);
|
||||
|
||||
const getCurrentEffectIndex = useMemo(() => {
|
||||
const getCurrentEffectIndex = useMemo(() =>
|
||||
{
|
||||
return getSelectedEffectIndex(selectedEffectName);
|
||||
}, [ selectedEffectName, getSelectedEffectIndex ]);
|
||||
|
||||
const getCurrentEffect = useMemo(() => {
|
||||
const getCurrentEffect = useMemo(() =>
|
||||
{
|
||||
if (!selectedEffectName) return null;
|
||||
return selectedEffects[getCurrentEffectIndex] || null;
|
||||
}, [ selectedEffectName, getCurrentEffectIndex, selectedEffects ]);
|
||||
|
||||
const setSelectedEffectAlpha = useCallback((alpha: number) => {
|
||||
const setSelectedEffectAlpha = useCallback((alpha: number) =>
|
||||
{
|
||||
const index = getCurrentEffectIndex;
|
||||
if (index === -1) return;
|
||||
|
||||
setSelectedEffects(prevValue => {
|
||||
setSelectedEffects(prevValue =>
|
||||
{
|
||||
const clone = [ ...prevValue ];
|
||||
const currentEffect = clone[index];
|
||||
clone[index] = new RoomCameraWidgetSelectedEffect(currentEffect.effect, alpha);
|
||||
@@ -73,8 +82,10 @@ export const CameraWidgetEditorView: FC<CameraWidgetEditorViewProps> = props =>
|
||||
});
|
||||
}, [ getCurrentEffectIndex ]);
|
||||
|
||||
const processAction = useCallback((type: string, effectName: string = null) => {
|
||||
switch (type) {
|
||||
const processAction = useCallback((type: string, effectName: string = null) =>
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case 'close':
|
||||
onClose();
|
||||
return;
|
||||
@@ -102,7 +113,8 @@ export const CameraWidgetEditorView: FC<CameraWidgetEditorViewProps> = props =>
|
||||
const existingIndex = getSelectedEffectIndex(effectName);
|
||||
if (existingIndex === -1) return;
|
||||
|
||||
setSelectedEffects(prevValue => {
|
||||
setSelectedEffects(prevValue =>
|
||||
{
|
||||
const clone = [ ...prevValue ];
|
||||
clone.splice(existingIndex, 1);
|
||||
return clone;
|
||||
@@ -141,10 +153,12 @@ export const CameraWidgetEditorView: FC<CameraWidgetEditorViewProps> = props =>
|
||||
}
|
||||
}, [ availableEffects, selectedEffectName, currentPictureUrl, getSelectedEffectIndex, onCancel, onCheckout, onClose ]);
|
||||
|
||||
useEffect(() => {
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!stableTexture) return;
|
||||
|
||||
const processThumbnails = async () => {
|
||||
const processThumbnails = async () =>
|
||||
{
|
||||
const renderedEffects = await Promise.all(
|
||||
availableEffects.map(effect =>
|
||||
GetRoomCameraWidgetManager().applyEffects(stableTexture, [ new RoomCameraWidgetSelectedEffect(effect, 1) ], false)
|
||||
@@ -155,24 +169,28 @@ export const CameraWidgetEditorView: FC<CameraWidgetEditorViewProps> = props =>
|
||||
processThumbnails();
|
||||
}, [ stableTexture, availableEffects ]);
|
||||
|
||||
useEffect(() => {
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!stableTexture) return;
|
||||
|
||||
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
|
||||
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
debounceTimerRef.current = setTimeout(() =>
|
||||
{
|
||||
const id = ++requestIdRef.current;
|
||||
|
||||
GetRoomCameraWidgetManager()
|
||||
.applyEffects(stableTexture, selectedEffects, false)
|
||||
.then(imageElement => {
|
||||
.then(imageElement =>
|
||||
{
|
||||
if (id !== requestIdRef.current) return;
|
||||
setCurrentPictureUrl(imageElement.src);
|
||||
})
|
||||
.catch(error => NitroLogger.error('Failed to apply effects to picture', error));
|
||||
}, 50);
|
||||
|
||||
return () => {
|
||||
return () =>
|
||||
{
|
||||
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
|
||||
};
|
||||
}, [ stableTexture, selectedEffects ]);
|
||||
|
||||
@@ -19,22 +19,22 @@ export const CameraWidgetEffectListItemView: FC<CameraWidgetEffectListItemViewPr
|
||||
const { effect = null, thumbnailUrl = null, isActive = false, isLocked = false, selectEffect = null, removeEffect = null } = props;
|
||||
|
||||
return (
|
||||
<LayoutGridItem itemActive={ isActive } title={ LocalizeText(!isLocked ? (`camera.effect.name.${ effect.name }`) : `camera.effect.required.level ${ effect.minLevel }`) } onClick={ event => (!isActive && selectEffect()) }>
|
||||
{ isActive &&
|
||||
<LayoutGridItem itemActive={ isActive } title={ LocalizeText(!isLocked ? (`camera.effect.name.${ effect.name }`) : `camera.effect.required.level ${ effect.minLevel }`) } onClick={ event => (!isActive && selectEffect()) }>
|
||||
{ isActive &&
|
||||
<Button className="rounded-circle remove-effect" variant="danger" onClick={ removeEffect }>
|
||||
<FaTimes className="fa-icon" />
|
||||
</Button> }
|
||||
{ !isLocked && (thumbnailUrl && thumbnailUrl.length > 0) &&
|
||||
{ !isLocked && (thumbnailUrl && thumbnailUrl.length > 0) &&
|
||||
<div className="effect-thumbnail-image border">
|
||||
<img alt="" src={ thumbnailUrl } />
|
||||
</div> }
|
||||
{ isLocked &&
|
||||
{ isLocked &&
|
||||
<Text bold center>
|
||||
<div>
|
||||
<FaLock className="fa-icon" />
|
||||
</div>
|
||||
{ effect.minLevel }
|
||||
</Text> }
|
||||
</LayoutGridItem>
|
||||
</LayoutGridItem>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,8 +25,8 @@ export const CameraWidgetEffectListView: FC<CameraWidgetEffectListViewProps> = p
|
||||
const isActive = (selectedEffects.findIndex(selectedEffect => (selectedEffect.effect.name === effect.name)) > -1);
|
||||
|
||||
// return <CameraWidgetEffectListItemView key={ index } effect={ effect } isActive={ isActive } isLocked={ (effect.minLevel > myLevel) } removeEffect={ () => processAction('remove_effect', effect.name) } selectEffect={ () => processAction('select_effect', effect.name) } thumbnailUrl={ ((thumbnailUrl && thumbnailUrl.thumbnailUrl) || null) } />;
|
||||
|
||||
return <CameraWidgetEffectListItemView key={ index } effect={ effect } thumbnailUrl={ ((thumbnailUrl && thumbnailUrl.thumbnailUrl) || null) } isActive={ isActive } isLocked={ (effect.minLevel > myLevel) } selectEffect={ () => processAction('select_effect', effect.name) } removeEffect={ () => processAction('remove_effect', effect.name) } />
|
||||
|
||||
return <CameraWidgetEffectListItemView key={ index } effect={ effect } thumbnailUrl={ ((thumbnailUrl && thumbnailUrl.thumbnailUrl) || null) } isActive={ isActive } isLocked={ (effect.minLevel > myLevel) } selectEffect={ () => processAction('select_effect', effect.name) } removeEffect={ () => processAction('remove_effect', effect.name) } />;
|
||||
}) }
|
||||
</Grid>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { GetSessionDataManager } from '@nitrots/nitro-renderer';
|
||||
import { FC, useState } from 'react';
|
||||
import { CalendarItemState, ICalendarItem, LocalizeText } from '../../api';
|
||||
import { Button, Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
|
||||
import { useHasPermission } from '../../hooks';
|
||||
import { CalendarItemView } from './CalendarItemView';
|
||||
|
||||
interface CalendarViewProps
|
||||
@@ -23,6 +23,7 @@ export const CalendarView: FC<CalendarViewProps> = props =>
|
||||
const { onClose = null, campaignName = null, currentDay = null, numDays = null, missedDays = null, openedDays = null, openPackage = null, receivedProducts = null } = props;
|
||||
const [ selectedDay, setSelectedDay ] = useState(currentDay);
|
||||
const [ index, setIndex ] = useState(Math.max(0, (selectedDay - 1)));
|
||||
const isModerator = useHasPermission('acc_calendar_force');
|
||||
|
||||
const getDayState = (day: number) =>
|
||||
{
|
||||
@@ -109,7 +110,7 @@ export const CalendarView: FC<CalendarViewProps> = props =>
|
||||
<Text>{ dayMessage(selectedDay) }</Text>
|
||||
</div>
|
||||
<div>
|
||||
{ GetSessionDataManager().isModerator &&
|
||||
{ isModerator &&
|
||||
<Button variant="danger" onClick={ forceOpen }>Force open</Button> }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { CatalogAdminCreateOfferComposer, CatalogAdminCreatePageComposer, CatalogAdminDeleteOfferComposer, CatalogAdminDeletePageComposer, CatalogAdminMoveOfferComposer, CatalogAdminMovePageComposer, CatalogAdminPublishComposer, CatalogAdminResultEvent, CatalogAdminSaveOfferComposer, CatalogAdminSavePageComposer } from '@nitrots/nitro-renderer';
|
||||
import { createContext, FC, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { ICatalogNode, IPurchasableOffer, NotificationAlertType, SendMessageComposer } from '../../api';
|
||||
import { useCatalog, useMessageEvent, useNotification } from '../../hooks';
|
||||
import { useCatalogUiState, useMessageEvent, useNotification } from '../../hooks';
|
||||
|
||||
export interface IPageEditData
|
||||
{
|
||||
pageId?: number;
|
||||
caption: string;
|
||||
captionSave: string;
|
||||
parentId: number;
|
||||
catalogMode: string;
|
||||
pageLayout: string;
|
||||
iconImage: number;
|
||||
enabled: string;
|
||||
visible: string;
|
||||
minRank: number;
|
||||
@@ -76,7 +78,7 @@ export const useCatalogAdmin = () => useContext(CatalogAdminContext);
|
||||
|
||||
export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children }) =>
|
||||
{
|
||||
const { currentType } = useCatalog();
|
||||
const { currentType } = useCatalogUiState();
|
||||
const [ adminMode, setAdminMode ] = useState(false);
|
||||
const [ editingOffer, setEditingOffer ] = useState<IPurchasableOffer | null>(null);
|
||||
const [ editingPageData, setEditingPageData ] = useState(false);
|
||||
@@ -88,7 +90,6 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children })
|
||||
const pendingActionRef = useRef<string | null>(null);
|
||||
const { simpleAlert = null } = useNotification();
|
||||
|
||||
// Keyboard shortcuts: Esc to close edit panels
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!adminMode) return;
|
||||
@@ -97,7 +98,10 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children })
|
||||
{
|
||||
if(e.key === 'Escape')
|
||||
{
|
||||
if(editingOffer) { setEditingOffer(null); e.preventDefault(); return; }
|
||||
if(editingOffer)
|
||||
{
|
||||
setEditingOffer(null); e.preventDefault(); return;
|
||||
}
|
||||
if(editingPageData || editingRootPage || editingPageNode)
|
||||
{
|
||||
setEditingPageData(false);
|
||||
@@ -173,11 +177,13 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children })
|
||||
setLoading(true);
|
||||
setLastError(null);
|
||||
pendingActionRef.current = 'savePage';
|
||||
|
||||
SendMessageComposer(new CatalogAdminSavePageComposer(
|
||||
data.pageId || 0, data.caption, data.caption, data.pageLayout, 0,
|
||||
data.pageId || 0, data.caption, data.captionSave, data.pageLayout, data.iconImage,
|
||||
data.minRank, data.visible === '1', data.enabled === '1',
|
||||
data.orderNum, data.parentId,
|
||||
data.pageHeadline || '', data.pageTeaser || '', data.pageTextDetails || '', currentType, data.catalogMode
|
||||
data.pageHeadline || '', data.pageTeaser || '', data.pageTextDetails || '', currentType, data.catalogMode,
|
||||
data.pageText1 || ''
|
||||
));
|
||||
}, [ currentType ]);
|
||||
|
||||
@@ -187,7 +193,7 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children })
|
||||
setLastError(null);
|
||||
pendingActionRef.current = 'createPage';
|
||||
SendMessageComposer(new CatalogAdminCreatePageComposer(
|
||||
data.caption, data.caption, data.pageLayout, 0,
|
||||
data.caption, data.captionSave, data.pageLayout, data.iconImage,
|
||||
data.minRank, data.visible === '1', data.enabled === '1',
|
||||
data.orderNum, data.parentId, currentType, data.catalogMode
|
||||
));
|
||||
@@ -280,7 +286,7 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children })
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CatalogAdminContext.Provider value={ {
|
||||
<CatalogAdminContext value={ {
|
||||
adminMode, setAdminMode,
|
||||
editingOffer, setEditingOffer,
|
||||
editingPageData, setEditingPageData,
|
||||
@@ -293,6 +299,6 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children })
|
||||
publishCatalog
|
||||
} }>
|
||||
{ children }
|
||||
</CatalogAdminContext.Provider>
|
||||
</CatalogAdminContext>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { AddLinkEventTracker, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect } from 'react';
|
||||
import { FaCog, FaEdit, FaEye, FaEyeSlash, FaPlus, FaTrash } from 'react-icons/fa';
|
||||
import { CatalogType, LocalizeText } from '../../api';
|
||||
import { NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common';
|
||||
import { useCatalog } from '../../hooks';
|
||||
import { CatalogType, GetConfigurationValue, LocalizeText } from '../../api';
|
||||
import { Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common';
|
||||
import { useCatalogActions, useCatalogData, useCatalogUiState, useHasPermission } from '../../hooks';
|
||||
import { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext';
|
||||
import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView';
|
||||
import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView';
|
||||
@@ -18,14 +18,19 @@ import { MarketplacePostOfferView } from './views/page/layout/marketplace/Market
|
||||
|
||||
const CatalogClassicViewInner: FC<{}> = () =>
|
||||
{
|
||||
const { isVisible = false, setIsVisible = null, rootNode = null, currentPage = null, navigationHidden = false, setNavigationHidden = null, activeNodes = [], searchResult = null, setSearchResult = null, openPageByName = null, openPageByOfferId = null, activateNode = null, openCatalogByType = null, toggleCatalogByType = null, currentType = CatalogType.NORMAL } = useCatalog();
|
||||
const { rootNode = null, currentPage = null, searchResult = null } = useCatalogData();
|
||||
const { isVisible = false, setIsVisible = null, navigationHidden = false, setNavigationHidden = null, activeNodes = [], setSearchResult = null, currentType = CatalogType.NORMAL } = useCatalogUiState();
|
||||
const { openPageByName = null, openPageByOfferId = null, activateNode = null, openCatalogByType = null, toggleCatalogByType = null } = useCatalogActions();
|
||||
const catalogAdmin = useCatalogAdmin();
|
||||
const adminMode = catalogAdmin?.adminMode ?? false;
|
||||
const setAdminMode = catalogAdmin?.setAdminMode ?? (() => {});
|
||||
const setAdminMode = catalogAdmin?.setAdminMode ?? (() =>
|
||||
{});
|
||||
const hasPendingChanges = catalogAdmin?.hasPendingChanges ?? false;
|
||||
const publishCatalog = catalogAdmin?.publishCatalog ?? (() => {});
|
||||
const publishCatalog = catalogAdmin?.publishCatalog ?? (() =>
|
||||
{});
|
||||
const loading = catalogAdmin?.loading ?? false;
|
||||
const isMod = GetSessionDataManager().isModerator;
|
||||
|
||||
const isMod = useHasPermission('acc_catalogfurni');
|
||||
const buildersClubHeaderStyle = (currentType === CatalogType.BUILDER)
|
||||
? { borderColor: '#d79d2e', borderBottomColor: '#000', background: 'linear-gradient(180deg, #d89f2d 0%, #c68515 100%)' }
|
||||
: undefined;
|
||||
@@ -148,13 +153,19 @@ const CatalogClassicViewInner: FC<{}> = () =>
|
||||
{ adminMode &&
|
||||
<div className="flex items-center gap-0.5 ml-1" onClick={ e => e.stopPropagation() }>
|
||||
<FaEdit className="text-[8px] text-primary cursor-pointer hover:text-dark" title={ LocalizeText('catalog.admin.edit.title') }
|
||||
onClick={ () => { catalogAdmin.setEditingPageNode(child); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true); } } />
|
||||
onClick={ () =>
|
||||
{
|
||||
catalogAdmin.setEditingPageNode(child); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true);
|
||||
} } />
|
||||
<span className="cursor-pointer" title={ isHidden ? LocalizeText('catalog.admin.show') : LocalizeText('catalog.admin.hide') }
|
||||
onClick={ () => catalogAdmin.togglePageVisible(child.pageId) }>
|
||||
{ isHidden ? <FaEye className="text-[8px] text-success" /> : <FaEyeSlash className="text-[8px] text-muted" /> }
|
||||
</span>
|
||||
<FaTrash className="text-[8px] text-danger cursor-pointer hover:text-red-800" title={ LocalizeText('catalog.admin.delete.title') }
|
||||
onClick={ () => { if(confirm(LocalizeText('catalog.admin.delete.category.confirm', [ 'name' ], [ child.localization ]))) catalogAdmin.deletePage(child.pageId); } } />
|
||||
onClick={ () =>
|
||||
{
|
||||
if(confirm(LocalizeText('catalog.admin.delete.category.confirm', [ 'name' ], [ child.localization ]))) catalogAdmin.deletePage(child.pageId);
|
||||
} } />
|
||||
</div> }
|
||||
</div>
|
||||
</NitroCardTabsItemView>
|
||||
@@ -171,14 +182,17 @@ const CatalogClassicViewInner: FC<{}> = () =>
|
||||
<div className="flex items-center gap-2 mb-1 nitro-catalog-classic-admin-actions">
|
||||
<button
|
||||
className="flex items-center gap-1 text-[9px] text-success hover:text-green-800 cursor-pointer transition-colors"
|
||||
onClick={ () => catalogAdmin.createPage({ caption: 'New Category', catalogMode: currentType, pageLayout: 'default_3x3', minRank: 1, visible: '1', enabled: '1', orderNum: 99, parentId: rootNode.pageId }) }
|
||||
onClick={ () => catalogAdmin.createPage({ caption: 'New Category', captionSave: 'New Category', catalogMode: currentType, pageLayout: 'default_3x3', iconImage: 0, minRank: 1, visible: '1', enabled: '1', orderNum: 99, parentId: rootNode.pageId }) }
|
||||
>
|
||||
<FaPlus className="text-[8px]" />
|
||||
<span>{ LocalizeText('catalog.admin.new') }</span>
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1 text-[9px] text-primary hover:text-dark cursor-pointer transition-colors"
|
||||
onClick={ () => { catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(true); catalogAdmin.setEditingPageData(true); } }
|
||||
onClick={ () =>
|
||||
{
|
||||
catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(true); catalogAdmin.setEditingPageData(true);
|
||||
} }
|
||||
>
|
||||
<FaEdit className="text-[8px]" />
|
||||
<span>{ LocalizeText('catalog.admin.root') }</span>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { AddLinkEventTracker, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { FaCog, FaEdit, FaEye, FaEyeSlash, FaHeart, FaPlus, FaStar, FaTrash } from 'react-icons/fa';
|
||||
import { CatalogType, LocalizeText } from '../../api';
|
||||
import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common';
|
||||
import { useCatalog, useCatalogFavorites } from '../../hooks';
|
||||
import { useCatalogActions, useCatalogData, useCatalogFavorites, useCatalogUiState, useHasPermission } from '../../hooks';
|
||||
import { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext';
|
||||
import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView';
|
||||
import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView';
|
||||
@@ -18,17 +18,21 @@ import { MarketplacePostOfferView } from './views/page/layout/marketplace/Market
|
||||
|
||||
const CatalogModernViewInner: FC<{}> = () =>
|
||||
{
|
||||
const { isVisible = false, setIsVisible = null, rootNode = null, currentPage = null, navigationHidden = false, setNavigationHidden = null, activeNodes = [], searchResult = null, setSearchResult = null, openPageByName = null, openPageByOfferId = null, activateNode = null, openCatalogByType = null, toggleCatalogByType = null, currentType = CatalogType.NORMAL } = useCatalog();
|
||||
const { rootNode = null, currentPage = null, searchResult = null } = useCatalogData();
|
||||
const { isVisible = false, setIsVisible = null, navigationHidden = false, setNavigationHidden = null, activeNodes = [], setSearchResult = null, currentType = CatalogType.NORMAL } = useCatalogUiState();
|
||||
const { openPageByName = null, openPageByOfferId = null, activateNode = null, openCatalogByType = null, toggleCatalogByType = null } = useCatalogActions();
|
||||
const catalogAdmin = useCatalogAdmin();
|
||||
const adminMode = catalogAdmin?.adminMode ?? false;
|
||||
const setAdminMode = catalogAdmin?.setAdminMode ?? (() => {});
|
||||
const setAdminMode = catalogAdmin?.setAdminMode ?? (() =>
|
||||
{});
|
||||
const hasPendingChanges = catalogAdmin?.hasPendingChanges ?? false;
|
||||
const publishCatalog = catalogAdmin?.publishCatalog ?? (() => {});
|
||||
const publishCatalog = catalogAdmin?.publishCatalog ?? (() =>
|
||||
{});
|
||||
const loading = catalogAdmin?.loading ?? false;
|
||||
const { favoriteOfferIds, favoritePageIds } = useCatalogFavorites();
|
||||
const [ showFavorites, setShowFavorites ] = useState(false);
|
||||
|
||||
const isMod = GetSessionDataManager().isModerator;
|
||||
const isMod = useHasPermission('acc_catalogfurni');
|
||||
const totalFavs = favoriteOfferIds.length + favoritePageIds.length;
|
||||
const buildersClubHeaderStyle = (currentType === CatalogType.BUILDER)
|
||||
? { borderColor: '#d79d2e', borderBottomColor: '#000', background: 'linear-gradient(180deg, #d89f2d 0%, #c68515 100%)' }
|
||||
@@ -162,7 +166,7 @@ const CatalogModernViewInner: FC<{}> = () =>
|
||||
<button
|
||||
className="flex items-center gap-1 text-[9px] text-success hover:text-green-800 cursor-pointer transition-colors"
|
||||
title={ LocalizeText('catalog.admin.new.root.category') }
|
||||
onClick={ () => catalogAdmin.createPage({ caption: 'New Category', catalogMode: currentType, pageLayout: 'default_3x3', minRank: 1, visible: '1', enabled: '1', orderNum: 99, parentId: rootNode.pageId }) }
|
||||
onClick={ () => catalogAdmin.createPage({ caption: 'New Category', captionSave: 'New Category', catalogMode: currentType, pageLayout: 'default_3x3', iconImage: 0, minRank: 1, visible: '1', enabled: '1', orderNum: 99, parentId: rootNode.pageId }) }
|
||||
>
|
||||
<FaPlus className="text-[8px]" />
|
||||
<span className="whitespace-nowrap">{ LocalizeText('catalog.admin.new') }</span>
|
||||
@@ -170,7 +174,10 @@ const CatalogModernViewInner: FC<{}> = () =>
|
||||
<button
|
||||
className="flex items-center gap-1 text-[9px] text-primary hover:text-dark cursor-pointer transition-colors"
|
||||
title={ LocalizeText('catalog.admin.edit.root') }
|
||||
onClick={ () => { catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(true); catalogAdmin.setEditingPageData(true); } }
|
||||
onClick={ () =>
|
||||
{
|
||||
catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(true); catalogAdmin.setEditingPageData(true);
|
||||
} }
|
||||
>
|
||||
<FaEdit className="text-[8px]" />
|
||||
<span className="whitespace-nowrap">{ LocalizeText('catalog.admin.root') }</span>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { FC } from 'react';
|
||||
import { GetConfigurationValue } from '../../api';
|
||||
import { useCatalog } from '../../hooks';
|
||||
import { useCatalogData } from '../../hooks';
|
||||
import { CatalogClassicView } from './CatalogClassicView';
|
||||
import { CatalogModernView } from './CatalogModernView';
|
||||
|
||||
export const CatalogView: FC<{}> = () =>
|
||||
{
|
||||
const { catalogLocalizationVersion = 0 } = useCatalog();
|
||||
const { catalogLocalizationVersion = 0 } = useCatalogData();
|
||||
const useNewStyle = GetConfigurationValue<boolean>('catalog.style.new', false);
|
||||
|
||||
if(useNewStyle) return (
|
||||
|
||||
@@ -2,12 +2,12 @@ import { FC, useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { FaSave, FaSpinner, FaTimes, FaTrash } from 'react-icons/fa';
|
||||
import { LocalizeText } from '../../../../api';
|
||||
import { useCatalog } from '../../../../hooks';
|
||||
import { useCatalogData } from '../../../../hooks';
|
||||
import { IOfferEditData, useCatalogAdmin } from '../../CatalogAdminContext';
|
||||
|
||||
export const CatalogAdminOfferEditView: FC<{}> = () =>
|
||||
{
|
||||
const { currentPage = null } = useCatalog();
|
||||
const { currentPage = null } = useCatalogData();
|
||||
const catalogAdmin = useCatalogAdmin();
|
||||
const editingOffer = catalogAdmin?.editingOffer ?? null;
|
||||
const setEditingOffer = catalogAdmin?.setEditingOffer;
|
||||
@@ -91,9 +91,10 @@ export const CatalogAdminOfferEditView: FC<{}> = () =>
|
||||
orderNumber
|
||||
};
|
||||
|
||||
const success = isNew ? await createOffer(data) : await saveOffer(data);
|
||||
if(isNew) createOffer(data);
|
||||
else saveOffer(data);
|
||||
|
||||
if(success && setEditingOffer) setEditingOffer(null);
|
||||
if(setEditingOffer) setEditingOffer(null);
|
||||
};
|
||||
|
||||
const handleDelete = () =>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { FaSave, FaSpinner, FaTimes, FaTrash } from 'react-icons/fa';
|
||||
import { FaLanguage, FaSave, FaSpinner, FaTimes, FaTrash } from 'react-icons/fa';
|
||||
import { CatalogType, LocalizeText } from '../../../../api';
|
||||
import { useCatalog } from '../../../../hooks';
|
||||
import { useCatalogData, useCatalogUiState, useTranslationActions, useTranslationState } from '../../../../hooks';
|
||||
import { IPageEditData, useCatalogAdmin } from '../../CatalogAdminContext';
|
||||
|
||||
const LAYOUT_OPTIONS = [
|
||||
@@ -15,13 +15,15 @@ const LAYOUT_OPTIONS = [
|
||||
];
|
||||
|
||||
const MODE_OPTIONS = [
|
||||
{ value: CatalogType.NORMAL, label: 'Normale' },
|
||||
{ value: 'BOTH', label: 'Entrambi' }
|
||||
{ value: 'NORMAL', label: 'Normal' },
|
||||
{ value: 'BUILDER', label: 'Builder' },
|
||||
{ value: 'BOTH', label: 'Both' }
|
||||
];
|
||||
|
||||
export const CatalogAdminPageEditView: FC<{}> = () =>
|
||||
{
|
||||
const { currentPage = null, activeNodes = [], rootNode = null, currentType = CatalogType.NORMAL } = useCatalog();
|
||||
const { currentPage = null, rootNode = null } = useCatalogData();
|
||||
const { activeNodes = [], currentType = CatalogType.NORMAL } = useCatalogUiState();
|
||||
const catalogAdmin = useCatalogAdmin();
|
||||
const editingPageData = catalogAdmin?.editingPageData ?? false;
|
||||
const editingRootPage = catalogAdmin?.editingRootPage ?? false;
|
||||
@@ -29,17 +31,22 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
|
||||
const loading = catalogAdmin?.loading ?? false;
|
||||
|
||||
const [ caption, setCaption ] = useState('');
|
||||
const [ catalogMode, setCatalogMode ] = useState(CatalogType.NORMAL);
|
||||
const [ captionSave, setCaptionSave ] = useState('');
|
||||
const [ catalogMode, setCatalogMode ] = useState<string>('NORMAL');
|
||||
const [ pageLayout, setPageLayout ] = useState('default_3x3');
|
||||
const [ iconImage, setIconImage ] = useState(0);
|
||||
const [ minRank, setMinRank ] = useState(1);
|
||||
const [ visible, setVisible ] = useState('1');
|
||||
const [ enabled, setEnabled ] = useState('1');
|
||||
const [ orderNum, setOrderNum ] = useState(0);
|
||||
|
||||
// Resolve what we're editing:
|
||||
// 1. editingPageNode (explicit node from sidebar click)
|
||||
// 2. editingRootPage (root button)
|
||||
// 3. current active page (from "Modifica Pagina" in layout)
|
||||
const [ parentId, setParentId ] = useState(-1);
|
||||
const [ pageText1, setPageText1 ] = useState('');
|
||||
const [ showTranslate, setShowTranslate ] = useState(false);
|
||||
const [ translateTargetLanguage, setTranslateTargetLanguage ] = useState('en');
|
||||
const [ isTranslating, setIsTranslating ] = useState(false);
|
||||
const [ translateError, setTranslateError ] = useState<string | null>(null);
|
||||
const { supportedLanguages = [], languagesLoading = false } = useTranslationState();
|
||||
const { translateText, ensureSupportedLanguagesLoaded } = useTranslationActions();
|
||||
const targetNode = editingPageNode
|
||||
? editingPageNode
|
||||
: editingRootPage
|
||||
@@ -61,12 +68,26 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
|
||||
if(!editingPageData || !targetNode) return;
|
||||
|
||||
setCaption(targetNode.localization || '');
|
||||
setCatalogMode(currentType === CatalogType.BUILDER ? CatalogType.BUILDER : (currentType || CatalogType.NORMAL));
|
||||
setCaptionSave(targetNode.pageName || targetNode.localization || '');
|
||||
setCatalogMode(currentType === CatalogType.BUILDER ? 'BUILDER' : 'NORMAL');
|
||||
setPageLayout(currentPage?.layoutCode || 'default_3x3');
|
||||
setIconImage(targetNode.iconId ?? 0);
|
||||
setVisible(targetNode.isVisible ? '1' : '0');
|
||||
setEnabled('1');
|
||||
setMinRank(1);
|
||||
setOrderNum(0);
|
||||
const matchesLoadedPage = currentPage && targetPageId === currentPage.pageId;
|
||||
const existingText1 = matchesLoadedPage && currentPage.localization
|
||||
? currentPage.localization.getText(0)
|
||||
: '';
|
||||
setPageText1(existingText1 || '');
|
||||
setShowTranslate(false);
|
||||
setIsTranslating(false);
|
||||
setTranslateError(null);
|
||||
const wireParentId = targetNode.parentId;
|
||||
setParentId(typeof wireParentId === 'number' && wireParentId !== -1
|
||||
? wireParentId
|
||||
: (targetNode.parent ? targetNode.parent.pageId : -1));
|
||||
}, [ editingPageData, targetNode, currentPage, currentType ]);
|
||||
|
||||
if(!editingPageData || !targetNode) return null;
|
||||
@@ -77,23 +98,65 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
|
||||
{
|
||||
if(!catalogAdmin?.savePage) return;
|
||||
|
||||
const parentNode = targetNode.parent;
|
||||
|
||||
const data: IPageEditData = {
|
||||
pageId: targetPageId,
|
||||
caption,
|
||||
captionSave,
|
||||
catalogMode,
|
||||
pageLayout,
|
||||
iconImage,
|
||||
minRank,
|
||||
visible,
|
||||
enabled,
|
||||
orderNum,
|
||||
parentId: parentNode ? parentNode.pageId : -1,
|
||||
parentId,
|
||||
pageText1,
|
||||
};
|
||||
|
||||
const success = await catalogAdmin.savePage(data);
|
||||
catalogAdmin.savePage(data);
|
||||
|
||||
if(success) closeForm();
|
||||
closeForm();
|
||||
};
|
||||
|
||||
const openTranslate = () =>
|
||||
{
|
||||
const next = !showTranslate;
|
||||
setShowTranslate(next);
|
||||
setTranslateError(null);
|
||||
if(next) ensureSupportedLanguagesLoaded();
|
||||
};
|
||||
|
||||
const runTranslate = async () =>
|
||||
{
|
||||
if(!pageText1.trim().length)
|
||||
{
|
||||
setTranslateError('Nothing to translate yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
if(!translateTargetLanguage)
|
||||
{
|
||||
setTranslateError('Pick a language first.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTranslating(true);
|
||||
setTranslateError(null);
|
||||
|
||||
try
|
||||
{
|
||||
const result = await translateText(pageText1, translateTargetLanguage);
|
||||
setPageText1(result?.translatedText || pageText1);
|
||||
setShowTranslate(false);
|
||||
}
|
||||
catch(error)
|
||||
{
|
||||
setTranslateError((error as Error)?.message || 'Translation failed.');
|
||||
}
|
||||
finally
|
||||
{
|
||||
setIsTranslating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () =>
|
||||
@@ -101,16 +164,16 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
|
||||
if(!catalogAdmin?.deletePage || isRoot) return;
|
||||
if(!confirm(LocalizeText('catalog.admin.delete.page.confirm', [ 'name' ], [ targetNode.localization ]))) return;
|
||||
|
||||
const success = await catalogAdmin.deletePage(targetPageId);
|
||||
catalogAdmin.deletePage(targetPageId);
|
||||
|
||||
if(success) closeForm();
|
||||
closeForm();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded border-2 border-card-grid-item-border p-2.5 mb-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[11px] font-bold text-primary uppercase tracking-wide">
|
||||
{ isRoot ? LocalizeText('catalog.admin.edit.root') : `${ LocalizeText('catalog.admin.edit') } ${ targetNode.localization } (#${ targetPageId })` }
|
||||
{ isRoot ? LocalizeText('catalog.admin.edit.root') : `${ LocalizeText('catalog.admin.edit') } ${ targetNode.localization }` }
|
||||
</span>
|
||||
<FaTimes className="text-muted cursor-pointer hover:text-danger text-[10px]" onClick={ closeForm } />
|
||||
</div>
|
||||
@@ -124,13 +187,19 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
|
||||
<label className="text-[9px] text-muted uppercase font-bold">Min Rank</label>
|
||||
<input className={ inputClass } min={ 1 } type="number" value={ minRank } onChange={ e => setMinRank(parseInt(e.target.value) || 1) } />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 col-span-2">
|
||||
<label className="text-[9px] text-muted uppercase font-bold">Caption Save (Localisation Key)</label>
|
||||
<input className={ inputClass } value={ captionSave } onChange={ e => setCaptionSave(e.target.value) } />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<label className="text-[9px] text-muted uppercase font-bold">Icon Image</label>
|
||||
<input className={ inputClass } min={ 0 } type="number" value={ iconImage } onChange={ e => setIconImage(parseInt(e.target.value) || 0) } />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<label className="text-[9px] text-muted uppercase font-bold">Mode</label>
|
||||
{ currentType === CatalogType.BUILDER
|
||||
? <div className={ `${ inputClass } flex items-center min-h-[28px] bg-gray-100 text-muted` }>Builders Club</div>
|
||||
: <select className={ inputClass } value={ catalogMode } onChange={ e => setCatalogMode(e.target.value) }>
|
||||
{ MODE_OPTIONS.map(option => <option key={ option.value } value={ option.value }>{ option.label }</option>) }
|
||||
</select> }
|
||||
<select className={ inputClass } value={ catalogMode } onChange={ e => setCatalogMode(e.target.value) }>
|
||||
{ MODE_OPTIONS.map(option => <option key={ option.value } value={ option.value }>{ option.label }</option>) }
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<label className="text-[9px] text-muted uppercase font-bold">Layout</label>
|
||||
@@ -142,6 +211,10 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
|
||||
<label className="text-[9px] text-muted uppercase font-bold">{ LocalizeText('catalog.admin.order') }</label>
|
||||
<input className={ inputClass } min={ 0 } type="number" value={ orderNum } onChange={ e => setOrderNum(parseInt(e.target.value) || 0) } />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<label className="text-[9px] text-muted uppercase font-bold">Parent ID</label>
|
||||
<input className={ inputClass } disabled={ isRoot } type="number" value={ parentId } onChange={ e => setParentId(parseInt(e.target.value) || -1) } />
|
||||
</div>
|
||||
<div className="flex items-end gap-2 pb-0.5">
|
||||
<label className="flex items-center gap-1 text-[10px] cursor-pointer">
|
||||
<input className="accent-primary" checked={ visible === '1' } type="checkbox" onChange={ e => setVisible(e.target.checked ? '1' : '0') } />
|
||||
@@ -152,6 +225,50 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
|
||||
{ LocalizeText('catalog.admin.enabled') }
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 col-span-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[9px] text-muted uppercase font-bold">Page Text 1 <span className="text-muted normal-case font-normal opacity-70">(leave blank to keep current)</span></label>
|
||||
<button
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-bold text-primary border border-primary/40 hover:bg-primary/10 transition-colors cursor-pointer disabled:opacity-50"
|
||||
disabled={ isTranslating || !pageText1.trim().length }
|
||||
title="Translate via Google Translate"
|
||||
type="button"
|
||||
onClick={ openTranslate }>
|
||||
{ isTranslating ? <FaSpinner className="text-[8px] animate-spin" /> : <FaLanguage className="text-[10px]" /> }
|
||||
Translate
|
||||
</button>
|
||||
</div>
|
||||
{ showTranslate &&
|
||||
<div className="flex items-center gap-1 mb-1 p-1 bg-gray-50 border border-card-grid-item-border rounded">
|
||||
<select
|
||||
className={ `${ inputClass } flex-1` }
|
||||
disabled={ isTranslating || languagesLoading }
|
||||
value={ translateTargetLanguage }
|
||||
onChange={ e => setTranslateTargetLanguage(e.target.value) }>
|
||||
{ languagesLoading && !supportedLanguages.length &&
|
||||
<option value="">Loading languages…</option> }
|
||||
{ supportedLanguages.map(lang => (
|
||||
<option key={ lang.code } value={ lang.code }>{ lang.name } ({ lang.code })</option>
|
||||
)) }
|
||||
</select>
|
||||
<button
|
||||
className="px-2 py-1 rounded text-[10px] font-bold bg-primary text-white hover:bg-secondary transition-colors cursor-pointer disabled:opacity-50"
|
||||
disabled={ isTranslating || !translateTargetLanguage || !pageText1.trim().length }
|
||||
type="button"
|
||||
onClick={ runTranslate }>
|
||||
{ isTranslating ? <FaSpinner className="text-[8px] animate-spin" /> : 'Apply' }
|
||||
</button>
|
||||
<button
|
||||
className="px-2 py-1 rounded text-[10px] font-bold text-muted border border-card-grid-item-border hover:bg-gray-100 transition-colors cursor-pointer"
|
||||
disabled={ isTranslating }
|
||||
type="button"
|
||||
onClick={ () => { setShowTranslate(false); setTranslateError(null); } }>
|
||||
Cancel
|
||||
</button>
|
||||
</div> }
|
||||
{ translateError && <span className="text-[9px] text-danger">{ translateError }</span> }
|
||||
<textarea className={ `${ inputClass } min-h-[60px] resize-y` } value={ pageText1 } onChange={ e => setPageText1(e.target.value) } />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-2">
|
||||
|
||||
@@ -2,11 +2,12 @@ import { GetTickerTime } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useMemo, useState } from 'react';
|
||||
import { CatalogType, FriendlyTime, LocalizeText } from '../../../../api';
|
||||
import buildersClubIcon from '../../../../assets/images/toolbar/icons/buildersclub.png';
|
||||
import { useCatalog } from '../../../../hooks';
|
||||
import { useCatalogData, useCatalogUiState } from '../../../../hooks';
|
||||
|
||||
export const CatalogBuildersClubStatusView: FC = () =>
|
||||
{
|
||||
const { currentType = CatalogType.NORMAL, furniCount = 0, furniLimit = 0, secondsLeft = 0, secondsLeftWithGrace = 0, updateTime = 0 } = useCatalog();
|
||||
const { furniCount = 0, furniLimit = 0, secondsLeft = 0, secondsLeftWithGrace = 0, updateTime = 0 } = useCatalogData();
|
||||
const { currentType = CatalogType.NORMAL } = useCatalogUiState();
|
||||
const [ ticker, setTicker ] = useState(() => GetTickerTime());
|
||||
|
||||
useEffect(() =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { FC } from 'react';
|
||||
import { GetConfigurationValue } from '../../../../api';
|
||||
|
||||
export interface CatalogHeaderViewProps
|
||||
@@ -9,12 +9,7 @@ export interface CatalogHeaderViewProps
|
||||
export const CatalogHeaderView: FC<CatalogHeaderViewProps> = props =>
|
||||
{
|
||||
const { imageUrl = null } = props;
|
||||
const [ displayImageUrl, setDisplayImageUrl ] = useState('');
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
setDisplayImageUrl(imageUrl ?? GetConfigurationValue<string>('catalog.asset.image.url').replace('%name%', 'catalog_header_roombuilder'));
|
||||
}, [ imageUrl ]);
|
||||
const displayImageUrl = imageUrl ?? GetConfigurationValue<string>('catalog.asset.image.url').replace('%name%', 'catalog_header_roombuilder');
|
||||
|
||||
return <div className="flex justify-center items-center w-full nitro-catalog-header">
|
||||
<img src={ displayImageUrl } onError={ ({ currentTarget }) =>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FC, useMemo } from 'react';
|
||||
import { FaHeart, FaStar, FaTimes } from 'react-icons/fa';
|
||||
import { ICatalogNode, LocalizeText } from '../../../../api';
|
||||
import { useCatalog, useCatalogFavorites } from '../../../../hooks';
|
||||
import { useCatalogActions, useCatalogData, useCatalogFavorites } from '../../../../hooks';
|
||||
import { CatalogIconView } from '../catalog-icon/CatalogIconView';
|
||||
|
||||
interface CatalogFavoritesViewProps
|
||||
@@ -13,7 +13,8 @@ export const CatalogFavoritesView: FC<CatalogFavoritesViewProps> = props =>
|
||||
{
|
||||
const { onClose } = props;
|
||||
const { favoriteOffers, favoritePageIds, toggleFavoritePage, toggleFavoriteOffer } = useCatalogFavorites();
|
||||
const { offersToNodes, activateNode, openPageByOfferId, rootNode } = useCatalog();
|
||||
const { offersToNodes, rootNode } = useCatalogData();
|
||||
const { activateNode, openPageByOfferId } = useCatalogActions();
|
||||
|
||||
const favoritePages = useMemo(() =>
|
||||
{
|
||||
@@ -93,13 +94,19 @@ export const CatalogFavoritesView: FC<CatalogFavoritesViewProps> = props =>
|
||||
<div
|
||||
key={ page.pageId }
|
||||
className="group/fav flex items-center gap-2 px-1.5 py-1 bg-card-grid-item rounded border border-card-grid-item-border hover:bg-card-grid-item-active cursor-pointer transition-all duration-100"
|
||||
onClick={ () => { activateNode(page.node); onClose(); } }
|
||||
onClick={ () =>
|
||||
{
|
||||
activateNode(page.node); onClose();
|
||||
} }
|
||||
>
|
||||
<CatalogIconView icon={ page.iconId } />
|
||||
<span className="text-[11px] flex-1 truncate font-medium">{ page.name }</span>
|
||||
<FaTimes
|
||||
className="text-[7px] text-muted opacity-0 group-hover/fav:opacity-100 hover:text-danger transition-all cursor-pointer"
|
||||
onClick={ e => { e.stopPropagation(); toggleFavoritePage(page.pageId); } }
|
||||
onClick={ e =>
|
||||
{
|
||||
e.stopPropagation(); toggleFavoritePage(page.pageId);
|
||||
} }
|
||||
/>
|
||||
</div>
|
||||
)) }
|
||||
@@ -118,7 +125,10 @@ export const CatalogFavoritesView: FC<CatalogFavoritesViewProps> = props =>
|
||||
<div
|
||||
key={ fav.offerId }
|
||||
className="group/fav flex items-center gap-2 px-1.5 py-1 bg-card-grid-item rounded border border-card-grid-item-border hover:bg-card-grid-item-active cursor-pointer transition-all duration-100"
|
||||
onClick={ () => { openPageByOfferId(fav.offerId); onClose(); } }
|
||||
onClick={ () =>
|
||||
{
|
||||
openPageByOfferId(fav.offerId); onClose();
|
||||
} }
|
||||
>
|
||||
{ /* Furni icon */ }
|
||||
<div className="w-7 h-7 flex items-center justify-center shrink-0 bg-white rounded border border-card-grid-item-border overflow-hidden">
|
||||
@@ -132,7 +142,10 @@ export const CatalogFavoritesView: FC<CatalogFavoritesViewProps> = props =>
|
||||
<span className="text-[11px] flex-1 truncate font-medium">{ fav.displayName }</span>
|
||||
<FaTimes
|
||||
className="text-[7px] text-muted opacity-0 group-hover/fav:opacity-100 hover:text-danger transition-all cursor-pointer"
|
||||
onClick={ e => { e.stopPropagation(); toggleFavoriteOffer(fav.offerId); } }
|
||||
onClick={ e =>
|
||||
{
|
||||
e.stopPropagation(); toggleFavoriteOffer(fav.offerId);
|
||||
} }
|
||||
/>
|
||||
</div>
|
||||
)) }
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
|
||||
import { ColorUtils, LocalizeText, MessengerFriend, ProductTypeEnum, SendMessageComposer } from '../../../../api';
|
||||
import { Button, Column, Flex, FormGroup, LayoutCurrencyIcon, LayoutFurniImageView, LayoutGiftTagView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
|
||||
import { CatalogEvent, CatalogInitGiftEvent, CatalogPurchasedEvent } from '../../../../events';
|
||||
import { useCatalog, useFriends, useMessageEvent, useUiEvent } from '../../../../hooks';
|
||||
import { useFriends, useGiftConfiguration, useMessageEvent, useUiEvent } from '../../../../hooks';
|
||||
import { classNames } from '../../../../layout';
|
||||
|
||||
let isBuyingGift = false;
|
||||
@@ -25,9 +25,8 @@ export const CatalogGiftView: FC<{}> = props =>
|
||||
const [ maxBoxIndex, setMaxBoxIndex ] = useState<number>(0);
|
||||
const [ maxRibbonIndex, setMaxRibbonIndex ] = useState<number>(0);
|
||||
const [ receiverNotFound, setReceiverNotFound ] = useState<boolean>(false);
|
||||
const { catalogOptions = null } = useCatalog();
|
||||
const { friends } = useFriends();
|
||||
const { giftConfiguration = null } = catalogOptions;
|
||||
const { data: giftConfiguration = null } = useGiftConfiguration();
|
||||
const [ boxTypes, setBoxTypes ] = useState<number[]>([]);
|
||||
const [ suggestions, setSuggestions ] = useState([]);
|
||||
const [ isAutocompleteVisible, setIsAutocompleteVisible ] = useState(true);
|
||||
@@ -133,7 +132,10 @@ export const CatalogGiftView: FC<{}> = props =>
|
||||
if(isBuyingGift) return;
|
||||
|
||||
isBuyingGift = true;
|
||||
setTimeout(() => { isBuyingGift = false; }, 10000);
|
||||
setTimeout(() =>
|
||||
{
|
||||
isBuyingGift = false;
|
||||
}, 10000);
|
||||
|
||||
SendMessageComposer(new PurchaseFromCatalogAsGiftComposer(pageId, offerId, extraData, receiverName, message, colourId, selectedBoxIndex, selectedRibbonIndex, showMyFace));
|
||||
return;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { FC } from 'react';
|
||||
import { LocalizeText } from '../../../../api';
|
||||
import { useCatalog } from '../../../../hooks';
|
||||
import { useCatalogActions, useCatalogUiState } from '../../../../hooks';
|
||||
|
||||
export const CatalogBreadcrumbView: FC<{}> = () =>
|
||||
{
|
||||
const { activeNodes = [], activateNode } = useCatalog();
|
||||
const { activeNodes = [] } = useCatalogUiState();
|
||||
const { activateNode } = useCatalogActions();
|
||||
|
||||
if(!activeNodes || activeNodes.length === 0)
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FC, useCallback, useRef, useState } from 'react';
|
||||
import { FaArrowsAlt, FaCaretDown, FaCaretUp, FaPlus, FaStar, FaTrash } from 'react-icons/fa';
|
||||
import { CatalogType, ICatalogNode, LocalizeText } from '../../../../api';
|
||||
import { useCatalog, useCatalogFavorites } from '../../../../hooks';
|
||||
import { useCatalogActions, useCatalogFavorites, useCatalogUiState } from '../../../../hooks';
|
||||
import { useCatalogAdmin } from '../../CatalogAdminContext';
|
||||
import { CatalogIconView } from '../catalog-icon/CatalogIconView';
|
||||
import { CatalogNavigationSetView } from './CatalogNavigationSetView';
|
||||
@@ -15,7 +15,8 @@ export interface CatalogNavigationItemViewProps
|
||||
export const CatalogNavigationItemView: FC<CatalogNavigationItemViewProps> = props =>
|
||||
{
|
||||
const { node = null, child = false } = props;
|
||||
const { activateNode = null, currentType = CatalogType.NORMAL } = useCatalog();
|
||||
const { activateNode = null } = useCatalogActions();
|
||||
const { currentType = CatalogType.NORMAL } = useCatalogUiState();
|
||||
const catalogAdmin = useCatalogAdmin();
|
||||
const adminMode = catalogAdmin?.adminMode ?? false;
|
||||
const { isFavoritePage, toggleFavoritePage } = useCatalogFavorites();
|
||||
@@ -100,8 +101,10 @@ export const CatalogNavigationItemView: FC<CatalogNavigationItemViewProps> = pro
|
||||
e.stopPropagation();
|
||||
catalogAdmin.createPage({
|
||||
caption: 'New Page',
|
||||
captionSave: 'New Page',
|
||||
catalogMode: currentType,
|
||||
pageLayout: 'default_3x3',
|
||||
iconImage: 0,
|
||||
minRank: 1,
|
||||
visible: '1',
|
||||
enabled: '1',
|
||||
@@ -125,8 +128,11 @@ export const CatalogNavigationItemView: FC<CatalogNavigationItemViewProps> = pro
|
||||
</div> }
|
||||
{ !adminMode && node.pageId > 0 &&
|
||||
<FaStar
|
||||
className={ `nitro-catalog-classic-navigation-favorite text-[8px] transition-all duration-100 cursor-pointer shrink-0 ${ isFav ? 'text-warning opacity-100' : 'text-muted opacity-0 group-hover/nav:opacity-100 hover:text-warning' }` }
|
||||
onClick={ e => { e.stopPropagation(); toggleFavoritePage(node.pageId); } }
|
||||
className={ `text-[8px] transition-all duration-100 cursor-pointer shrink-0 ${ isFav ? 'text-warning opacity-100' : 'text-muted opacity-0 group-hover/nav:opacity-100 hover:text-warning' }` }
|
||||
onClick={ e =>
|
||||
{
|
||||
e.stopPropagation(); toggleFavoritePage(node.pageId);
|
||||
} }
|
||||
/> }
|
||||
{ node.isBranch &&
|
||||
<span className="nitro-catalog-classic-navigation-caret text-[9px] text-muted shrink-0">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FC } from 'react';
|
||||
import { ICatalogNode } from '../../../../api';
|
||||
import { useCatalog } from '../../../../hooks';
|
||||
import { useCatalogData } from '../../../../hooks';
|
||||
import { CatalogNavigationItemView } from './CatalogNavigationItemView';
|
||||
import { CatalogNavigationSetView } from './CatalogNavigationSetView';
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface CatalogNavigationViewProps
|
||||
export const CatalogNavigationView: FC<CatalogNavigationViewProps> = props =>
|
||||
{
|
||||
const { node = null } = props;
|
||||
const { searchResult = null } = useCatalog();
|
||||
const { searchResult = null } = useCatalogData();
|
||||
|
||||
return (
|
||||
<div className="nitro-catalog-classic-navigation-list">
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FC, MouseEvent, useMemo, useState } from 'react';
|
||||
import { FaHeart } from 'react-icons/fa';
|
||||
import { CatalogType, IPurchasableOffer, Offer, ProductTypeEnum } from '../../../../../api';
|
||||
import { LayoutAvatarImageView, LayoutGridItem, LayoutGridItemProps } from '../../../../../common';
|
||||
import { useCatalog, useCatalogFavorites, useInventoryFurni } from '../../../../../hooks';
|
||||
import { useCatalogActions, useCatalogFavorites, useCatalogUiState, useInventoryFurni } from '../../../../../hooks';
|
||||
|
||||
interface CatalogGridOfferViewProps extends LayoutGridItemProps
|
||||
{
|
||||
@@ -15,7 +15,8 @@ export const CatalogGridOfferView: FC<CatalogGridOfferViewProps> = props =>
|
||||
{
|
||||
const { offer = null, selectOffer = null, itemActive = false, ...rest } = props;
|
||||
const [ isMouseDown, setMouseDown ] = useState(false);
|
||||
const { requestOfferToMover = null, currentType = CatalogType.NORMAL } = useCatalog();
|
||||
const { requestOfferToMover = null } = useCatalogActions();
|
||||
const { currentType = CatalogType.NORMAL } = useCatalogUiState();
|
||||
const { isVisible = false } = useInventoryFurni();
|
||||
const { isFavoriteOffer, toggleFavoriteOffer } = useCatalogFavorites();
|
||||
const isFav = offer ? isFavoriteOffer(offer.offerId) : false;
|
||||
@@ -78,7 +79,10 @@ export const CatalogGridOfferView: FC<CatalogGridOfferViewProps> = props =>
|
||||
<LayoutAvatarImageView direction={ 3 } figure={ offer.product.extraParam } headOnly={ true } /> }
|
||||
<div
|
||||
className={ `absolute top-0 right-0 z-10 p-0.5 cursor-pointer transition-opacity duration-100 ${ isFav ? 'opacity-100' : 'opacity-0 group-hover/tile:opacity-100' }` }
|
||||
onClick={ e => { e.stopPropagation(); e.preventDefault(); toggleFavoriteOffer(offer.offerId, offer.localizationName, iconUrl); } }
|
||||
onClick={ e =>
|
||||
{
|
||||
e.stopPropagation(); e.preventDefault(); toggleFavoriteOffer(offer.offerId, offer.localizationName, iconUrl);
|
||||
} }
|
||||
onMouseDown={ e => e.stopPropagation() }
|
||||
>
|
||||
<FaHeart className={ `text-[10px] drop-shadow transition-colors duration-100 ${ isFav ? 'text-danger' : 'text-muted hover:text-danger' }` } />
|
||||
|
||||
@@ -2,12 +2,13 @@ import { GetSessionDataManager, IFurnitureData } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { FaSearch, FaTimes } from 'react-icons/fa';
|
||||
import { CatalogPage, CatalogType, FilterCatalogNode, FurnitureOffer, ICatalogNode, ICatalogPage, IPurchasableOffer, LocalizeText, PageLocalization, SearchResult } from '../../../../../api';
|
||||
import { useCatalog } from '../../../../../hooks';
|
||||
import { useCatalogData, useCatalogUiState } from '../../../../../hooks';
|
||||
|
||||
export const CatalogSearchView: FC<{}> = () =>
|
||||
{
|
||||
const [ searchValue, setSearchValue ] = useState('');
|
||||
const { currentType = null, rootNode = null, searchResult = null, setSearchResult = null, setCurrentPage = null } = useCatalog();
|
||||
const { rootNode = null, searchResult = null } = useCatalogData();
|
||||
const { currentType = null, setSearchResult = null, setCurrentPage = null } = useCatalogUiState();
|
||||
|
||||
const normalizeSearchText = (value: string) => (value || '')
|
||||
.toLocaleLowerCase()
|
||||
@@ -48,6 +49,7 @@ export const CatalogSearchView: FC<{}> = () =>
|
||||
|
||||
const name = normalizeSearchText(furniture.name || '');
|
||||
const matchesSearch = name.includes(search);
|
||||
const isBuyable = (furniture.purchaseOfferId > -1) || (furniture.rentOfferId > -1);
|
||||
|
||||
if((currentType === CatalogType.BUILDER) && (furniture.purchaseOfferId === -1) && (furniture.rentOfferId === -1))
|
||||
{
|
||||
@@ -56,7 +58,7 @@ export const CatalogSearchView: FC<{}> = () =>
|
||||
if(matchesSearch) foundFurniLines.push(furniture.furniLine);
|
||||
}
|
||||
}
|
||||
else if(matchesSearch)
|
||||
else if(matchesSearch && isBuyable)
|
||||
{
|
||||
foundFurniture.push(furniture);
|
||||
|
||||
@@ -67,6 +69,10 @@ export const CatalogSearchView: FC<{}> = () =>
|
||||
|
||||
if(foundFurniture.length === 250) break;
|
||||
}
|
||||
else if(matchesSearch && furniture.furniLine && furniture.furniLine.length && (foundFurniLines.indexOf(furniture.furniLine) < 0))
|
||||
{
|
||||
foundFurniLines.push(furniture.furniLine);
|
||||
}
|
||||
}
|
||||
|
||||
const offers: IPurchasableOffer[] = [];
|
||||
@@ -81,7 +87,7 @@ export const CatalogSearchView: FC<{}> = () =>
|
||||
FilterCatalogNode(search, foundFurniLines, rootNode, nodes);
|
||||
|
||||
setSearchResult(new SearchResult(search, offers, nodes.filter(node => (node.isVisible))));
|
||||
setCurrentPage((new CatalogPage(-1, 'default_3x3', new PageLocalization([], []), offers, false, 1) as ICatalogPage));
|
||||
setCurrentPage((new CatalogPage(-1, 'default_3x3', new PageLocalization([], []), offers, false, 1)));
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FC } from 'react';
|
||||
import { LocalizeText, SanitizeHtml } from '../../../../../api';
|
||||
import { Column, Grid, Text } from '../../../../../common';
|
||||
import { useCatalog } from '../../../../../hooks';
|
||||
import { useCatalogData } from '../../../../../hooks';
|
||||
import { CatalogBadgeSelectorWidgetView } from '../widgets/CatalogBadgeSelectorWidgetView';
|
||||
import { CatalogFirstProductSelectorWidgetView } from '../widgets/CatalogFirstProductSelectorWidgetView';
|
||||
import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView';
|
||||
@@ -14,7 +14,7 @@ import { CatalogLayoutProps } from './CatalogLayout.types';
|
||||
export const CatalogLayoutBadgeDisplayView: FC<CatalogLayoutProps> = props =>
|
||||
{
|
||||
const { page = null } = props;
|
||||
const { currentOffer = null } = useCatalog();
|
||||
const { currentOffer = null } = useCatalogData();
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ClubOfferData, GetClubOffersMessageComposer, PurchaseFromCatalogComposer } from '@nitrots/nitro-renderer';
|
||||
import { ClubOfferData, PurchaseFromCatalogComposer } from '@nitrots/nitro-renderer';
|
||||
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { CatalogPurchaseState, LocalizeText, SanitizeHtml, SendMessageComposer } from '../../../../../api';
|
||||
import { Button, Column, Flex, Grid, LayoutCurrencyIcon, LayoutGridItem, LayoutLoadingSpinnerView, Text } from '../../../../../common';
|
||||
import { CatalogEvent, CatalogPurchaseFailureEvent, CatalogPurchasedEvent } from '../../../../../events';
|
||||
import { useCatalog, usePurse, useUiEvent } from '../../../../../hooks';
|
||||
import { useCatalogData, useClubOffers, usePurse, useUiEvent } from '../../../../../hooks';
|
||||
import { CatalogHeaderView } from '../../catalog-header/CatalogHeaderView';
|
||||
import { CatalogLayoutProps } from './CatalogLayout.types';
|
||||
|
||||
@@ -14,12 +14,12 @@ export const CatalogLayoutBuildersClubBuyView: FC<CatalogLayoutProps> = () =>
|
||||
{
|
||||
const [ pendingOffer, setPendingOffer ] = useState<ClubOfferData>(null);
|
||||
const [ purchaseState, setPurchaseState ] = useState(CatalogPurchaseState.NONE);
|
||||
const { currentPage = null, catalogOptions = null } = useCatalog();
|
||||
const { currentPage = null } = useCatalogData();
|
||||
const { getCurrencyAmount = null } = usePurse();
|
||||
const isPurchasingRef = useRef(false);
|
||||
const isAddonLayout = (currentPage?.layoutCode === 'builders_club_addons');
|
||||
const windowId = (isAddonLayout ? BUILDERS_CLUB_ADDONS_WINDOW_ID : BUILDERS_CLUB_WINDOW_ID);
|
||||
const offers = catalogOptions?.clubOffersByWindowId?.[windowId] || null;
|
||||
const { data: offers = null } = useClubOffers(windowId);
|
||||
|
||||
const onCatalogEvent = useCallback((event: CatalogEvent) =>
|
||||
{
|
||||
@@ -120,11 +120,6 @@ export const CatalogLayoutBuildersClubBuyView: FC<CatalogLayoutProps> = () =>
|
||||
return currentPage.localization.getText(1) || currentPage.localization.getText(2) || currentPage.localization.getText(0) || '';
|
||||
}, [ currentPage ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!offers) SendMessageComposer(new GetClubOffersMessageComposer(windowId));
|
||||
}, [ offers, windowId ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!offers || !offers.length) return;
|
||||
@@ -142,44 +137,45 @@ export const CatalogLayoutBuildersClubBuyView: FC<CatalogLayoutProps> = () =>
|
||||
{ currentPage?.localization?.getImage(0) &&
|
||||
<CatalogHeaderView imageUrl={ currentPage.localization.getImage(0) } /> }
|
||||
<Grid>
|
||||
<Column fullHeight justifyContent="between" overflow="hidden" size={ 7 }>
|
||||
<Column gap={ 1 } overflow="auto">
|
||||
{ offers && (offers.length > 0) && offers.map((offer, index) =>
|
||||
{
|
||||
const meta = getOfferMeta(offer);
|
||||
<Column fullHeight justifyContent="between" overflow="hidden" size={ 7 }>
|
||||
<Column gap={ 1 } overflow="auto">
|
||||
{ offers && (offers.length > 0) && offers.map((offer, index) =>
|
||||
{
|
||||
const meta = getOfferMeta(offer);
|
||||
|
||||
return (
|
||||
<LayoutGridItem key={ index } alignItems="center" center={ false } className="p-2" column={ false } itemActive={ pendingOffer?.offerId === offer.offerId } justifyContent="between" onClick={ () => {
|
||||
setPurchaseState(CatalogPurchaseState.NONE);
|
||||
setPendingOffer(offer);
|
||||
} }>
|
||||
<Column gap={ 0 }>
|
||||
<Text fontWeight="bold">{ getOfferName(offer) }</Text>
|
||||
{ meta.length > 0 && <Text small>{ meta }</Text> }
|
||||
</Column>
|
||||
<div className="flex flex-col gap-1">
|
||||
{ (offer.priceCredits > 0) &&
|
||||
return (
|
||||
<LayoutGridItem key={ index } alignItems="center" center={ false } className="p-2" column={ false } itemActive={ pendingOffer?.offerId === offer.offerId } justifyContent="between" onClick={ () =>
|
||||
{
|
||||
setPurchaseState(CatalogPurchaseState.NONE);
|
||||
setPendingOffer(offer);
|
||||
} }>
|
||||
<Column gap={ 0 }>
|
||||
<Text fontWeight="bold">{ getOfferName(offer) }</Text>
|
||||
{ meta.length > 0 && <Text small>{ meta }</Text> }
|
||||
</Column>
|
||||
<div className="flex flex-col gap-1">
|
||||
{ (offer.priceCredits > 0) &&
|
||||
<Flex alignItems="center" gap={ 1 } justifyContent="end">
|
||||
<Text>{ offer.priceCredits }</Text>
|
||||
<LayoutCurrencyIcon type={ -1 } />
|
||||
</Flex> }
|
||||
{ (offer.priceActivityPoints > 0) &&
|
||||
{ (offer.priceActivityPoints > 0) &&
|
||||
<Flex alignItems="center" gap={ 1 } justifyContent="end">
|
||||
<Text>{ offer.priceActivityPoints }</Text>
|
||||
<LayoutCurrencyIcon type={ offer.priceActivityPointsType } />
|
||||
</Flex> }
|
||||
</div>
|
||||
</LayoutGridItem>
|
||||
);
|
||||
}) }
|
||||
</div>
|
||||
</LayoutGridItem>
|
||||
);
|
||||
}) }
|
||||
</Column>
|
||||
</Column>
|
||||
</Column>
|
||||
<Column gap={ 2 } overflow="hidden" size={ 5 }>
|
||||
<Column center grow overflow="hidden">
|
||||
{ currentPage?.localization.getImage(1) && <img alt="" src={ currentPage.localization.getImage(1) } /> }
|
||||
{ pageDescription.length > 0 && <Text center dangerouslySetInnerHTML={ { __html: SanitizeHtml(pageDescription) } } overflow="auto" /> }
|
||||
</Column>
|
||||
{ pendingOffer &&
|
||||
<Column gap={ 2 } overflow="hidden" size={ 5 }>
|
||||
<Column center grow overflow="hidden">
|
||||
{ currentPage?.localization.getImage(1) && <img alt="" src={ currentPage.localization.getImage(1) } /> }
|
||||
{ pageDescription.length > 0 && <Text center dangerouslySetInnerHTML={ { __html: SanitizeHtml(pageDescription) } } overflow="auto" /> }
|
||||
</Column>
|
||||
{ pendingOffer &&
|
||||
<Column fullWidth gap={ 1 }>
|
||||
<Text fontWeight="bold">{ getOfferName(pendingOffer) }</Text>
|
||||
{ getOfferMeta(pendingOffer).length > 0 && <Text>{ getOfferMeta(pendingOffer) }</Text> }
|
||||
@@ -202,7 +198,7 @@ export const CatalogLayoutBuildersClubBuyView: FC<CatalogLayoutProps> = () =>
|
||||
</Flex>
|
||||
{ getPurchaseButton() }
|
||||
</Column> }
|
||||
</Column>
|
||||
</Column>
|
||||
</Grid>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FC, useMemo, useState } from 'react';
|
||||
import { FaFillDrip } from 'react-icons/fa';
|
||||
import { IPurchasableOffer, SanitizeHtml } from '../../../../../api';
|
||||
import { AutoGrid, Button, Column, Grid, LayoutGridItem, Text } from '../../../../../common';
|
||||
import { useCatalog } from '../../../../../hooks';
|
||||
import { useCatalogData, useCatalogUiState } from '../../../../../hooks';
|
||||
import { CatalogGridOfferView } from '../common/CatalogGridOfferView';
|
||||
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
|
||||
import { CatalogLimitedItemWidgetView } from '../widgets/CatalogLimitedItemWidgetView';
|
||||
@@ -22,7 +22,8 @@ export const CatalogLayoutColorGroupingView: FC<CatalogLayoutColorGroupViewProps
|
||||
{
|
||||
const { page = null } = props;
|
||||
const [ colorableItems, setColorableItems ] = useState<Map<string, number[]>>(new Map<string, number[]>());
|
||||
const { currentOffer = null, setCurrentOffer = null } = useCatalog();
|
||||
const { currentOffer = null } = useCatalogData();
|
||||
const { setCurrentOffer = null } = useCatalogUiState();
|
||||
const [ colorsShowing, setColorsShowing ] = useState<boolean>(false);
|
||||
|
||||
const sortByColorIndex = (a: IPurchasableOffer, b: IPurchasableOffer) =>
|
||||
|
||||
@@ -117,7 +117,10 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
|
||||
if(!prefixText.length) return;
|
||||
|
||||
const newColors: Record<number, string> = {};
|
||||
[ ...prefixText ].forEach((_, i) => { newColors[i] = customColorInput; });
|
||||
[ ...prefixText ].forEach((_, i) =>
|
||||
{
|
||||
newColors[i] = customColorInput;
|
||||
});
|
||||
setLetterColors(newColors);
|
||||
};
|
||||
|
||||
@@ -222,7 +225,10 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
|
||||
<Picker
|
||||
data={ data }
|
||||
locale="it"
|
||||
onEmojiSelect={ (emoji: { native: string }) => { setSelectedIcon(emoji.native); setShowIconPicker(false); } }
|
||||
onEmojiSelect={ (emoji: { native: string }) =>
|
||||
{
|
||||
setSelectedIcon(emoji.native); setShowIconPicker(false);
|
||||
} }
|
||||
theme="dark"
|
||||
previewPosition="none"
|
||||
skinTonePosition="search"
|
||||
@@ -268,7 +274,10 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
|
||||
borderRight: '1px solid rgba(0,0,0,0.1)',
|
||||
opacity: colorMode === 'single' ? 1 : 0.6
|
||||
} }
|
||||
onClick={ () => { setColorMode('single'); setSelectedLetterIndex(null); } }>
|
||||
onClick={ () =>
|
||||
{
|
||||
setColorMode('single'); setSelectedLetterIndex(null);
|
||||
} }>
|
||||
{ LocalizeText('catalog.prefix.color.single') }
|
||||
</button>
|
||||
<button
|
||||
@@ -277,7 +286,10 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
|
||||
background: colorMode === 'perLetter' ? 'rgba(59,130,246,0.25)' : 'rgba(0,0,0,0.1)',
|
||||
opacity: colorMode === 'perLetter' ? 1 : 0.6
|
||||
} }
|
||||
onClick={ () => { setColorMode('perLetter'); if(prefixText.length > 0) setSelectedLetterIndex(0); } }>
|
||||
onClick={ () =>
|
||||
{
|
||||
setColorMode('perLetter'); if(prefixText.length > 0) setSelectedLetterIndex(0);
|
||||
} }>
|
||||
{ LocalizeText('catalog.prefix.color.per.letter') }
|
||||
</button>
|
||||
</div>
|
||||
@@ -328,7 +340,10 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
|
||||
zIndex: isSelected ? 10 : 1,
|
||||
boxShadow: isSelected ? '0 0 8px rgba(59,130,246,0.3)' : 'none'
|
||||
} }
|
||||
onClick={ () => { setSelectedLetterIndex(i); setCustomColorInput(charColor); } }>
|
||||
onClick={ () =>
|
||||
{
|
||||
setSelectedLetterIndex(i); setCustomColorInput(charColor);
|
||||
} }>
|
||||
<span className="text-sm font-black" style={ { color: charColor } }>
|
||||
{ char }
|
||||
</span>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { FC } from 'react';
|
||||
import { FaEdit, FaPlus } from 'react-icons/fa';
|
||||
import { GetConfigurationValue, LocalizeText, ProductTypeEnum, SanitizeHtml } from '../../../../../api';
|
||||
import { Text } from '../../../../../common';
|
||||
import { useCatalog } from '../../../../../hooks';
|
||||
import { useCatalogData } from '../../../../../hooks';
|
||||
import { useCatalogAdmin } from '../../../CatalogAdminContext';
|
||||
import { CatalogHeaderView } from '../../catalog-header/CatalogHeaderView';
|
||||
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
|
||||
@@ -17,7 +17,7 @@ import { CatalogLayoutProps } from './CatalogLayout.types';
|
||||
export const CatalogLayoutDefaultView: FC<CatalogLayoutProps> = props =>
|
||||
{
|
||||
const { page = null } = props;
|
||||
const { currentOffer = null, currentPage = null } = useCatalog();
|
||||
const { currentOffer = null, currentPage = null } = useCatalogData();
|
||||
const catalogAdmin = useCatalogAdmin();
|
||||
const adminMode = catalogAdmin?.adminMode ?? false;
|
||||
|
||||
@@ -28,7 +28,10 @@ export const CatalogLayoutDefaultView: FC<CatalogLayoutProps> = props =>
|
||||
<div className="flex gap-2 nitro-catalog-classic-default-admin">
|
||||
<button
|
||||
className="flex items-center gap-1 text-[10px] text-primary hover:text-dark transition-colors cursor-pointer"
|
||||
onClick={ () => { catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true); } }
|
||||
onClick={ () =>
|
||||
{
|
||||
catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true);
|
||||
} }
|
||||
>
|
||||
<FaEdit className="text-[10px]" /> { LocalizeText('catalog.admin.edit.page') }
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FC } from 'react';
|
||||
import { SanitizeHtml } from '../../../../../api';
|
||||
import { Column, Grid, Text } from '../../../../../common';
|
||||
import { useCatalog } from '../../../../../hooks';
|
||||
import { useCatalogData } from '../../../../../hooks';
|
||||
import { CatalogGuildBadgeWidgetView } from '../widgets/CatalogGuildBadgeWidgetView';
|
||||
import { CatalogGuildSelectorWidgetView } from '../widgets/CatalogGuildSelectorWidgetView';
|
||||
import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView';
|
||||
@@ -13,7 +13,7 @@ import { CatalogLayoutProps } from './CatalogLayout.types';
|
||||
export const CatalogLayouGuildCustomFurniView: FC<CatalogLayoutProps> = props =>
|
||||
{
|
||||
const { page = null } = props;
|
||||
const { currentOffer = null } = useCatalog();
|
||||
const { currentOffer = null } = useCatalogData();
|
||||
|
||||
return (
|
||||
<Grid>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { CatalogGroupsComposer } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { SanitizeHtml, SendMessageComposer } from '../../../../../api';
|
||||
import { FC, useState } from 'react';
|
||||
import { SanitizeHtml } from '../../../../../api';
|
||||
import { Column, Grid, Text } from '../../../../../common';
|
||||
import { useCatalog } from '../../../../../hooks';
|
||||
import { useCatalogData, useCatalogUiState, useUserGroups } from '../../../../../hooks';
|
||||
import { CatalogFirstProductSelectorWidgetView } from '../widgets/CatalogFirstProductSelectorWidgetView';
|
||||
import { CatalogGuildSelectorWidgetView } from '../widgets/CatalogGuildSelectorWidgetView';
|
||||
import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView';
|
||||
@@ -13,13 +12,9 @@ export const CatalogLayouGuildForumView: FC<CatalogLayoutProps> = props =>
|
||||
{
|
||||
const { page = null } = props;
|
||||
const [ selectedGroupIndex, setSelectedGroupIndex ] = useState<number>(0);
|
||||
const { currentOffer = null, setCurrentOffer = null, catalogOptions = null } = useCatalog();
|
||||
const { groups = null } = catalogOptions;
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
SendMessageComposer(new CatalogGroupsComposer());
|
||||
}, [ page ]);
|
||||
const { currentOffer = null } = useCatalogData();
|
||||
const { setCurrentOffer = null } = useCatalogUiState();
|
||||
const { data: groups = null } = useUserGroups();
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { GetRoomAdPurchaseInfoComposer, GetUserEventCatsMessageComposer, PurchaseRoomAdMessageComposer, RoomAdPurchaseInfoEvent, RoomEntryData } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { LocalizeText, SendMessageComposer } from '../../../../../api';
|
||||
import { useNitroQuery } from '../../../../../api/nitro-query';
|
||||
import { Button, Column, Text } from '../../../../../common';
|
||||
import { useCatalog, useMessageEvent, useNavigator, useRoomPromote } from '../../../../../hooks';
|
||||
import { useCatalogUiState, useNavigator, useRoomPromote } from '../../../../../hooks';
|
||||
import { NitroInput } from '../../../../../layout';
|
||||
import { CatalogLayoutProps } from './CatalogLayout.types';
|
||||
|
||||
@@ -14,13 +15,20 @@ export const CatalogLayoutRoomAdsView: FC<CatalogLayoutProps> = props =>
|
||||
const [ eventName, setEventName ] = useState<string>('');
|
||||
const [ eventDesc, setEventDesc ] = useState<string>('');
|
||||
const [ roomId, setRoomId ] = useState<number>(-1);
|
||||
const [ availableRooms, setAvailableRooms ] = useState<RoomEntryData[]>([]);
|
||||
const [ extended, setExtended ] = useState<boolean>(false);
|
||||
const [ categoryId, setCategoryId ] = useState<number>(1);
|
||||
const { categories = null } = useNavigator();
|
||||
const { setIsVisible = null } = useCatalog();
|
||||
const { setIsVisible = null } = useCatalogUiState();
|
||||
const { promoteInformation, isExtended, setIsExtended } = useRoomPromote();
|
||||
|
||||
const { data: availableRooms = [] } = useNitroQuery<RoomAdPurchaseInfoEvent, RoomEntryData[]>({
|
||||
key: [ 'nitro', 'catalog', 'room-ad-purchase-info' ],
|
||||
request: () => new GetRoomAdPurchaseInfoComposer(),
|
||||
parser: RoomAdPurchaseInfoEvent,
|
||||
select: e => e.getParser()?.rooms ?? [],
|
||||
staleTime: 60_000
|
||||
});
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(isExtended)
|
||||
@@ -62,18 +70,8 @@ export const CatalogLayoutRoomAdsView: FC<CatalogLayoutProps> = props =>
|
||||
resetData();
|
||||
};
|
||||
|
||||
useMessageEvent<RoomAdPurchaseInfoEvent>(RoomAdPurchaseInfoEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
if(!parser) return;
|
||||
|
||||
setAvailableRooms(parser.rooms);
|
||||
});
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
SendMessageComposer(new GetRoomAdPurchaseInfoComposer());
|
||||
// TODO: someone needs to fix this for morningstar
|
||||
SendMessageComposer(new GetUserEventCatsMessageComposer());
|
||||
}, []);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { GetOfficialSongIdMessageComposer, GetSoundManager, MusicPriorities, Off
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { GetConfigurationValue, LocalizeText, ProductTypeEnum, SanitizeHtml, SendMessageComposer } from '../../../../../api';
|
||||
import { Button, Column, Grid, LayoutImage, Text } from '../../../../../common';
|
||||
import { useCatalog, useMessageEvent } from '../../../../../hooks';
|
||||
import { useCatalogData, useMessageEvent } from '../../../../../hooks';
|
||||
import { CatalogHeaderView } from '../../catalog-header/CatalogHeaderView';
|
||||
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
|
||||
import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView';
|
||||
@@ -18,7 +18,7 @@ export const CatalogLayoutSoundMachineView: FC<CatalogLayoutProps> = props =>
|
||||
const { page = null } = props;
|
||||
const [ songId, setSongId ] = useState(-1);
|
||||
const [ officialSongId, setOfficialSongId ] = useState('');
|
||||
const { currentOffer = null, currentPage = null } = useCatalog();
|
||||
const { currentOffer = null, currentPage = null } = useCatalogData();
|
||||
|
||||
const previewSong = (previewSongId: number) => GetSoundManager().musicController?.playSong(previewSongId, MusicPriorities.PRIORITY_PURCHASE_PREVIEW, 15, 0, 0, 0);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FC, useEffect } from 'react';
|
||||
import { SanitizeHtml } from '../../../../../api';
|
||||
import { Column, Grid, Text } from '../../../../../common';
|
||||
import { useCatalog } from '../../../../../hooks';
|
||||
import { useCatalogData } from '../../../../../hooks';
|
||||
import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView';
|
||||
import { CatalogSpacesWidgetView } from '../widgets/CatalogSpacesWidgetView';
|
||||
import { CatalogTotalPriceWidget } from '../widgets/CatalogTotalPriceWidget';
|
||||
@@ -11,7 +11,7 @@ import { CatalogLayoutProps } from './CatalogLayout.types';
|
||||
export const CatalogLayoutSpacesView: FC<CatalogLayoutProps> = props =>
|
||||
{
|
||||
const { page = null } = props;
|
||||
const { currentOffer = null, roomPreviewer = null } = useCatalog();
|
||||
const { currentOffer = null, roomPreviewer = null } = useCatalogData();
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@ import { FC, useEffect, useState } from 'react';
|
||||
import { FaEdit, FaPen, FaPlus, FaTrophy } from 'react-icons/fa';
|
||||
import { LocalizeText, ProductTypeEnum, SanitizeHtml } from '../../../../../api';
|
||||
import { Text } from '../../../../../common';
|
||||
import { useCatalog } from '../../../../../hooks';
|
||||
import { useCatalogData, useCatalogUiState } from '../../../../../hooks';
|
||||
import { useCatalogAdmin } from '../../../CatalogAdminContext';
|
||||
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
|
||||
import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView';
|
||||
@@ -15,7 +15,8 @@ export const CatalogLayoutTrophiesView: FC<CatalogLayoutProps> = props =>
|
||||
{
|
||||
const { page = null } = props;
|
||||
const [ trophyText, setTrophyText ] = useState<string>('');
|
||||
const { currentOffer = null, setPurchaseOptions = null } = useCatalog();
|
||||
const { currentOffer = null } = useCatalogData();
|
||||
const { setPurchaseOptions = null } = useCatalogUiState();
|
||||
const catalogAdmin = useCatalogAdmin();
|
||||
const adminMode = catalogAdmin?.adminMode ?? false;
|
||||
|
||||
@@ -42,7 +43,10 @@ export const CatalogLayoutTrophiesView: FC<CatalogLayoutProps> = props =>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="flex items-center gap-1 text-[10px] text-primary hover:text-dark transition-colors cursor-pointer"
|
||||
onClick={ () => { catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true); } }
|
||||
onClick={ () =>
|
||||
{
|
||||
catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true);
|
||||
} }
|
||||
>
|
||||
<FaEdit className="text-[10px]" /> { LocalizeText('catalog.admin.edit.page') }
|
||||
</button>
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
import { ClubOfferData, GetClubOffersMessageComposer, PurchaseFromCatalogComposer } from '@nitrots/nitro-renderer';
|
||||
import { ClubOfferData, GiftReceiverNotFoundEvent, PurchaseFromCatalogAsGiftComposer, PurchaseFromCatalogComposer } from '@nitrots/nitro-renderer';
|
||||
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { CatalogPurchaseState, LocalizeText, SanitizeHtml, SendMessageComposer } from '../../../../../api';
|
||||
import { AutoGrid, Button, Column, Flex, Grid, LayoutCurrencyIcon, LayoutGridItem, LayoutLoadingSpinnerView, Text } from '../../../../../common';
|
||||
import { AutoGrid, Button, Column, Flex, Grid, LayoutCurrencyIcon, LayoutLoadingSpinnerView, Text } from '../../../../../common';
|
||||
import { CatalogEvent, CatalogPurchaseFailureEvent, CatalogPurchasedEvent } from '../../../../../events';
|
||||
import { useCatalog, usePurse, useUiEvent } from '../../../../../hooks';
|
||||
import { useCatalogData, useClubOffers, useMessageEvent, usePurse, useUiEvent, useUserDataSnapshot } from '../../../../../hooks';
|
||||
import { CatalogLayoutProps } from './CatalogLayout.types';
|
||||
|
||||
const VIP_WINDOW_ID = 1;
|
||||
|
||||
export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
|
||||
{
|
||||
const [ pendingOffer, setPendingOffer ] = useState<ClubOfferData>(null);
|
||||
const [ purchaseState, setPurchaseState ] = useState(CatalogPurchaseState.NONE);
|
||||
const { currentPage = null, catalogOptions = null } = useCatalog();
|
||||
const [ giftMode, setGiftMode ] = useState(false);
|
||||
const [ giftRecipient, setGiftRecipient ] = useState('');
|
||||
const [ giftError, setGiftError ] = useState<string | null>(null);
|
||||
const [ giftSuccess, setGiftSuccess ] = useState(false);
|
||||
const { currentPage = null } = useCatalogData();
|
||||
const { purse = null, getCurrencyAmount = null } = usePurse();
|
||||
const { clubOffers = null, clubOffersByWindowId = null } = (catalogOptions || {});
|
||||
const offers = clubOffersByWindowId?.[1] || clubOffers;
|
||||
const { data: offers = null } = useClubOffers(VIP_WINDOW_ID);
|
||||
const { userName: ownUserName = '' } = useUserDataSnapshot();
|
||||
const isPurchasingRef = useRef<boolean>(false);
|
||||
const wasGiftPurchaseRef = useRef<boolean>(false);
|
||||
const giftSuccessTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const isSelfGift = giftMode && !!ownUserName && giftRecipient.trim().toLowerCase() === ownUserName.toLowerCase();
|
||||
|
||||
const onCatalogEvent = useCallback((event: CatalogEvent) =>
|
||||
{
|
||||
@@ -23,9 +33,20 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
|
||||
case CatalogPurchasedEvent.PURCHASE_SUCCESS:
|
||||
isPurchasingRef.current = false;
|
||||
setPurchaseState(CatalogPurchaseState.NONE);
|
||||
setGiftError(null);
|
||||
if(wasGiftPurchaseRef.current)
|
||||
{
|
||||
wasGiftPurchaseRef.current = false;
|
||||
setGiftRecipient('');
|
||||
setGiftMode(false);
|
||||
setGiftSuccess(true);
|
||||
if(giftSuccessTimerRef.current) clearTimeout(giftSuccessTimerRef.current);
|
||||
giftSuccessTimerRef.current = setTimeout(() => setGiftSuccess(false), 3500);
|
||||
}
|
||||
return;
|
||||
case CatalogPurchaseFailureEvent.PURCHASE_FAILED:
|
||||
isPurchasingRef.current = false;
|
||||
wasGiftPurchaseRef.current = false;
|
||||
setPurchaseState(CatalogPurchaseState.FAILED);
|
||||
return;
|
||||
}
|
||||
@@ -34,6 +55,21 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
|
||||
useUiEvent(CatalogPurchasedEvent.PURCHASE_SUCCESS, onCatalogEvent);
|
||||
useUiEvent(CatalogPurchaseFailureEvent.PURCHASE_FAILED, onCatalogEvent);
|
||||
|
||||
useEffect(() => () =>
|
||||
{
|
||||
if(giftSuccessTimerRef.current) clearTimeout(giftSuccessTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const handleGiftReceiverNotFound = useCallback(() =>
|
||||
{
|
||||
if(!isPurchasingRef.current) return;
|
||||
isPurchasingRef.current = false;
|
||||
setPurchaseState(CatalogPurchaseState.NONE);
|
||||
setGiftError(LocalizeText('catalog.gift_wrapping.receiver_not_found.title'));
|
||||
}, []);
|
||||
|
||||
useMessageEvent<GiftReceiverNotFoundEvent>(GiftReceiverNotFoundEvent, handleGiftReceiverNotFound);
|
||||
|
||||
const getOfferText = useCallback((offer: ClubOfferData) =>
|
||||
{
|
||||
let offerText = '';
|
||||
@@ -88,16 +124,39 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
|
||||
const purchaseSubscription = useCallback(() =>
|
||||
{
|
||||
if(!pendingOffer || isPurchasingRef.current) return;
|
||||
if(giftMode && !giftRecipient.trim()) return;
|
||||
if(isSelfGift) return;
|
||||
|
||||
isPurchasingRef.current = true;
|
||||
wasGiftPurchaseRef.current = giftMode;
|
||||
setPurchaseState(CatalogPurchaseState.PURCHASE);
|
||||
SendMessageComposer(new PurchaseFromCatalogComposer(currentPage.pageId, pendingOffer.offerId, null, 1));
|
||||
}, [ pendingOffer, currentPage ]);
|
||||
setGiftError(null);
|
||||
setGiftSuccess(false);
|
||||
|
||||
if(giftMode)
|
||||
{
|
||||
SendMessageComposer(new PurchaseFromCatalogAsGiftComposer(currentPage.pageId, pendingOffer.offerId, '', giftRecipient.trim(), '', 0, 0, 0, false));
|
||||
}
|
||||
else
|
||||
{
|
||||
SendMessageComposer(new PurchaseFromCatalogComposer(currentPage.pageId, pendingOffer.offerId, null, 1));
|
||||
}
|
||||
}, [ pendingOffer, currentPage, giftMode, giftRecipient, isSelfGift ]);
|
||||
|
||||
const setOffer = useCallback((offer: ClubOfferData) =>
|
||||
{
|
||||
setPurchaseState(CatalogPurchaseState.NONE);
|
||||
setPendingOffer(offer);
|
||||
setGiftError(null);
|
||||
setGiftSuccess(false);
|
||||
if(!offer?.giftable) setGiftMode(false);
|
||||
}, []);
|
||||
|
||||
const onGiftRecipientChange = useCallback((value: string) =>
|
||||
{
|
||||
setGiftRecipient(value);
|
||||
setGiftError(null);
|
||||
setGiftSuccess(false);
|
||||
}, []);
|
||||
|
||||
const getPurchaseButton = useCallback(() =>
|
||||
@@ -114,24 +173,22 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
|
||||
return <Button fullWidth variant="danger">{ LocalizeText('catalog.alert.notenough.activitypoints.title.' + pendingOffer.priceActivityPointsType) }</Button>;
|
||||
}
|
||||
|
||||
const giftBlocked = giftMode && (!giftRecipient.trim() || isSelfGift);
|
||||
const buyLabel = giftMode ? LocalizeText('catalog.gift_wrapping.give_gift') : LocalizeText('buy');
|
||||
|
||||
switch(purchaseState)
|
||||
{
|
||||
case CatalogPurchaseState.CONFIRM:
|
||||
return <Button fullWidth variant="warning" onClick={ purchaseSubscription }>{ LocalizeText('catalog.marketplace.confirm_title') }</Button>;
|
||||
return <Button disabled={ giftBlocked } fullWidth variant="warning" onClick={ purchaseSubscription }>{ LocalizeText('catalog.marketplace.confirm_title') }</Button>;
|
||||
case CatalogPurchaseState.PURCHASE:
|
||||
return <Button disabled fullWidth variant="primary"><LayoutLoadingSpinnerView /></Button>;
|
||||
case CatalogPurchaseState.FAILED:
|
||||
return <Button disabled fullWidth variant="danger">{ LocalizeText('generic.failed') }</Button>;
|
||||
case CatalogPurchaseState.NONE:
|
||||
default:
|
||||
return <Button fullWidth variant="success" onClick={ () => setPurchaseState(CatalogPurchaseState.CONFIRM) }>{ LocalizeText('buy') }</Button>;
|
||||
return <Button disabled={ giftBlocked } fullWidth variant="success" onClick={ () => setPurchaseState(CatalogPurchaseState.CONFIRM) }>{ buyLabel }</Button>;
|
||||
}
|
||||
}, [ pendingOffer, purchaseState, purchaseSubscription, getCurrencyAmount ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!offers) SendMessageComposer(new GetClubOffersMessageComposer(1));
|
||||
}, [ offers ]);
|
||||
}, [ pendingOffer, purchaseState, purchaseSubscription, getCurrencyAmount, giftMode, giftRecipient, isSelfGift ]);
|
||||
|
||||
return (
|
||||
<Grid>
|
||||
@@ -139,25 +196,29 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
|
||||
<AutoGrid className="nitro-catalog-layout-vip-buy-grid" columnCount={ 1 }>
|
||||
{ offers && (offers.length > 0) && offers.map((offer, index) =>
|
||||
{
|
||||
const isActive = (pendingOffer === offer);
|
||||
|
||||
return (
|
||||
<LayoutGridItem key={ index } alignItems="center" center={ false } className="p-1" column={ false } itemActive={ pendingOffer === offer } justifyContent="between" onClick={ () => setOffer(offer) }>
|
||||
<i className="icon-hc-banner" />
|
||||
<Column gap={ 0 } justifyContent="end">
|
||||
<Text textEnd>{ getOfferText(offer) }</Text>
|
||||
<Flex gap={ 1 } justifyContent="end">
|
||||
{ (offer.priceCredits > 0) &&
|
||||
<Flex alignItems="center" gap={ 1 } justifyContent="end">
|
||||
<Text>{ offer.priceCredits }</Text>
|
||||
<LayoutCurrencyIcon type={ -1 } />
|
||||
</Flex> }
|
||||
{ (offer.priceActivityPoints > 0) &&
|
||||
<Flex alignItems="center" gap={ 1 } justifyContent="end">
|
||||
<Text>{ offer.priceActivityPoints }</Text>
|
||||
<LayoutCurrencyIcon type={ offer.priceActivityPointsType } />
|
||||
</Flex> }
|
||||
</Flex>
|
||||
</Column>
|
||||
</LayoutGridItem>
|
||||
<div key={ index } className={ 'nitro-vip-buy-offer flex flex-col gap-1.5 p-2 rounded-md border-2 cursor-pointer ' + (isActive ? 'active border-[#7a5500] bg-[#ffe066]' : 'border-[#b48a18] bg-[#fffbe7] hover:bg-[#fff5c4] hover:border-[#9c7610]') } onClick={ () => setOffer(offer) }>
|
||||
<div className="vip-offer-header flex items-center gap-2 pb-1.5 border-b border-dashed border-[#b48a18]">
|
||||
<span className="vip-offer-banner inline-flex items-center justify-center shrink-0 w-[34px] h-[20px]">
|
||||
<i className="nitro-icon icon-hc-banner" style={ { width: '34px', height: '20px', backgroundSize: 'contain', backgroundRepeat: 'no-repeat', backgroundPosition: 'center' } } />
|
||||
</span>
|
||||
<span className="vip-offer-title flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-bold text-[1.05rem] leading-tight text-[#2c2a25]">{ getOfferText(offer) }</span>
|
||||
</div>
|
||||
<div className="vip-offer-prices flex flex-col gap-1">
|
||||
{ (offer.priceCredits > 0) &&
|
||||
<span className="vip-offer-price flex items-center gap-1.5 font-bold text-[0.95rem] leading-tight text-[#4a473e] whitespace-nowrap">
|
||||
<LayoutCurrencyIcon type={ -1 } />
|
||||
<span>{ offer.priceCredits }</span>
|
||||
</span> }
|
||||
{ (offer.priceActivityPoints > 0) &&
|
||||
<span className="vip-offer-price flex items-center gap-1.5 font-bold text-[0.95rem] leading-tight text-[#4a473e] whitespace-nowrap">
|
||||
<LayoutCurrencyIcon type={ offer.priceActivityPointsType } />
|
||||
<span>{ offer.priceActivityPoints }</span>
|
||||
</span> }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}) }
|
||||
</AutoGrid>
|
||||
@@ -172,7 +233,7 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
|
||||
<Column fullWidth grow justifyContent="end">
|
||||
<Flex alignItems="end">
|
||||
<Column grow gap={ 0 }>
|
||||
<Text fontWeight="bold">{ getPurchaseHeader() }</Text>
|
||||
<Text fontWeight="bold">{ giftMode ? LocalizeText('catalog.purchase_confirmation.gift') : getPurchaseHeader() }</Text>
|
||||
<Text>{ getPurchaseValidUntil() }</Text>
|
||||
</Column>
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -188,6 +249,28 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
|
||||
</Flex> }
|
||||
</div>
|
||||
</Flex>
|
||||
{ pendingOffer.giftable &&
|
||||
<Column className="mt-1" gap={ 1 }>
|
||||
<Flex alignItems="center" gap={ 2 }>
|
||||
<label className="flex items-center gap-1 cursor-pointer text-sm">
|
||||
<input checked={ giftMode } className="cursor-pointer" type="checkbox" onChange={ event => { setGiftMode(event.target.checked); setGiftError(null); setGiftSuccess(false); } } />
|
||||
<span>{ LocalizeText('catalog.purchase_confirmation.gift') }</span>
|
||||
</label>
|
||||
{ giftMode &&
|
||||
<input
|
||||
className="flex-1 min-w-0 border border-[#b48a18] bg-white rounded px-2 py-1 text-sm"
|
||||
placeholder={ LocalizeText('catalog.gift_wrapping.receiver') }
|
||||
type="text"
|
||||
value={ giftRecipient }
|
||||
onChange={ event => onGiftRecipientChange(event.target.value) } /> }
|
||||
</Flex>
|
||||
{ giftMode && isSelfGift &&
|
||||
<Text className="text-[#b00020] text-xs">{ LocalizeText('catalog.gift_wrapping.cannot_send_to_self') }</Text> }
|
||||
{ giftMode && giftError && !isSelfGift &&
|
||||
<Text className="text-[#b00020] text-xs">{ giftError }</Text> }
|
||||
{ giftSuccess &&
|
||||
<Text className="text-[#1f7a1f] text-sm font-bold">{ LocalizeText('catalog.gift_wrapping.gift_sent') }</Text> }
|
||||
</Column> }
|
||||
{ getPurchaseButton() }
|
||||
</Column> }
|
||||
</Column>
|
||||
|
||||