Merge branch 'Dev' into merge-duckie-main-2026-05-06

This commit is contained in:
DuckieTM
2026-05-25 18:51:48 +02:00
committed by GitHub
340 changed files with 18499 additions and 7335 deletions
+242 -48
View File
@@ -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>
);
+14 -4
View File
@@ -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',
];
+39
View File
@@ -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', '' ]);
});
});
+21
View File
@@ -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;
});
};
+1
View File
@@ -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';
+26 -7
View File
@@ -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;
};
+7
View File
@@ -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;
+1 -1
View File
@@ -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
+1
View File
@@ -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[];
-14
View File
@@ -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;
}
+1
View File
@@ -24,4 +24,5 @@ export interface IPurchasableOffer
products: IProduct[];
itemIds: string;
haveOffer: boolean;
clone?(): IPurchasableOffer;
}
-1
View File
@@ -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';
+1 -1
View File
@@ -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 });
};
+1
View File
@@ -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';
+127
View File
@@ -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());
});
+2
View File
@@ -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 });
});
};
+2 -1
View File
@@ -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;
+2 -1
View File
@@ -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 -2
View File
@@ -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]));
};
}
+27 -67
View File
@@ -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>
);
};
+2 -2
View File
@@ -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;
}
+8 -3
View File
@@ -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 =>
+165
View File
@@ -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);
});
});
+194
View File
@@ -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({});
});
});
+100
View File
@@ -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');
});
});
+4 -1
View File
@@ -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;
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

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 ]) =>
{
+27 -7
View File
@@ -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 =>
+5 -5
View File
@@ -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 } />;
};
+1 -1
View File
@@ -19,4 +19,4 @@ export const ButtonGroup: FC<ButtonGroupProps> = props =>
}, [ classNames ]);
return <Base classNames={ getClassNames } { ...rest } />;
}
};
+3 -3
View File
@@ -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 -1
View File
@@ -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;
+1 -1
View File
@@ -145,4 +145,4 @@ export const Slider: FC<SliderProps> = props =>
) }
</Flex>
);
}
};
+16 -13
View File
@@ -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);
+3 -3
View File
@@ -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);
+1 -1
View File
@@ -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);
+56 -29
View File
@@ -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>
);
+2 -1
View File
@@ -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';
+9 -2
View File
@@ -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())
{
+15 -5
View File
@@ -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 } />;
+7 -4
View File
@@ -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()));
};
+5 -3
View File
@@ -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
View File
@@ -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';
+27 -5
View File
@@ -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 />
+48 -39
View File
@@ -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">
+70 -21
View File
@@ -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>
);
+3 -2
View File
@@ -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>
+15 -9
View File
@@ -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>
);
};
+26 -12
View File
@@ -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>
+15 -8
View File
@@ -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>
+2 -2
View File
@@ -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>

Some files were not shown because too many files have changed in this diff Show More