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
+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>
@@ -1,7 +1,7 @@
import { CreateLinkEvent, FrontPageItem } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect } from 'react';
import { Column, Grid } from '../../../../../../common';
import { useCatalog } from '../../../../../../hooks';
import { useCatalogData } from '../../../../../../hooks';
import { CatalogRedeemVoucherView } from '../../common/CatalogRedeemVoucherView';
import { CatalogLayoutProps } from '../CatalogLayout.types';
import { CatalogLayoutFrontPageItemView } from './CatalogLayoutFrontPageItemView';
@@ -9,7 +9,7 @@ import { CatalogLayoutFrontPageItemView } from './CatalogLayoutFrontPageItemView
export const CatalogLayoutFrontpage4View: FC<CatalogLayoutProps> = props =>
{
const { page = null, hideNavigation = null } = props;
const { frontPageItems = [] } = useCatalog();
const { frontPageItems = [] } = useCatalogData();
const selectItem = useCallback((item: FrontPageItem) =>
{
@@ -1,9 +1,9 @@
import { GetMarketplaceConfigurationMessageComposer, MakeOfferMessageComposer, MarketplaceConfigurationEvent } from '@nitrots/nitro-renderer';
import { MakeOfferMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { FurnitureItem, LocalizeText, ProductTypeEnum, SendMessageComposer } from '../../../../../../api';
import { Button, Column, Grid, LayoutFurniImageView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../../../common';
import { CatalogPostMarketplaceOfferEvent } from '../../../../../../events';
import { useCatalog, useMessageEvent, useNotification, useUiEvent } from '../../../../../../hooks';
import { useMarketplaceConfiguration, useNotification, useUiEvent } from '../../../../../../hooks';
import { NitroInput } from '../../../../../../layout';
let isPostingMarketplaceOffer = false;
@@ -13,8 +13,7 @@ export const MarketplacePostOfferView: FC<{}> = props =>
const [ item, setItem ] = useState<FurnitureItem>(null);
const [ askingPrice, setAskingPrice ] = useState(0);
const [ tempAskingPrice, setTempAskingPrice ] = useState('0');
const { catalogOptions = null, setCatalogOptions = null } = useCatalog();
const { marketplaceConfiguration = null } = catalogOptions;
const { data: marketplaceConfiguration = null } = useMarketplaceConfiguration({ enabled: !!item });
const { showConfirm = null } = useNotification();
const updateAskingPrice = (price: string) =>
@@ -28,29 +27,8 @@ export const MarketplacePostOfferView: FC<{}> = props =>
setAskingPrice(parseInt(price));
};
useMessageEvent<MarketplaceConfigurationEvent>(MarketplaceConfigurationEvent, event =>
{
const parser = event.getParser();
setCatalogOptions(prevValue =>
{
const newValue = { ...prevValue };
newValue.marketplaceConfiguration = parser;
return newValue;
});
});
useUiEvent<CatalogPostMarketplaceOfferEvent>(CatalogPostMarketplaceOfferEvent.POST_MARKETPLACE, event => setItem(event.item));
useEffect(() =>
{
if(!item || marketplaceConfiguration) return;
SendMessageComposer(new GetMarketplaceConfigurationMessageComposer());
}, [ item, marketplaceConfiguration ]);
useEffect(() =>
{
if(!item) return;
@@ -1,10 +1,10 @@
import { ApproveNameMessageComposer, ApproveNameMessageEvent, ColorConverter, GetSellablePetPalettesComposer, PurchaseFromCatalogComposer, SellablePetPaletteData } from '@nitrots/nitro-renderer';
import { ApproveNameMessageComposer, ApproveNameMessageEvent, ColorConverter, PurchaseFromCatalogComposer, SellablePetPaletteData } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { FaCheck, FaEdit, FaFillDrip, FaPaw, FaPlus, FaTimes } from 'react-icons/fa';
import { DispatchUiEvent, GetPetAvailableColors, GetPetIndexFromLocalization, LocalizeText, SanitizeHtml, SendMessageComposer } from '../../../../../../api';
import { LayoutGridItem, LayoutPetImageView } from '../../../../../../common';
import { CatalogPurchaseFailureEvent } from '../../../../../../events';
import { useCatalog, useMessageEvent } from '../../../../../../hooks';
import { useCatalogData, useCatalogUiState, useMessageEvent, useSellablePetPalette } from '../../../../../../hooks';
import { useCatalogAdmin } from '../../../../CatalogAdminContext';
import { CatalogAddOnBadgeWidgetView } from '../../widgets/CatalogAddOnBadgeWidgetView';
import { CatalogTotalPriceWidget } from '../../widgets/CatalogTotalPriceWidget';
@@ -23,10 +23,12 @@ export const CatalogLayoutPetView: FC<CatalogLayoutProps> = props =>
const [ petName, setPetName ] = useState('');
const [ approvalPending, setApprovalPending ] = useState(true);
const [ approvalResult, setApprovalResult ] = useState(-1);
const { currentOffer = null, setCurrentOffer = null, setPurchaseOptions = null, catalogOptions = null, roomPreviewer = null } = useCatalog();
const { currentOffer = null, roomPreviewer = null } = useCatalogData();
const { setCurrentOffer = null, setPurchaseOptions = null } = useCatalogUiState();
const catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
const { petPalettes = null } = catalogOptions;
const breed: string = (currentOffer?.product?.productData?.type as unknown as string) ?? '';
const { data: petPalette = null } = useSellablePetPalette(breed);
const getColor = useMemo(() =>
{
@@ -129,39 +131,25 @@ export const CatalogLayoutPetView: FC<CatalogLayoutProps> = props =>
useEffect(() =>
{
if(!currentOffer) return;
const productData = currentOffer.product.productData;
if(!productData) return;
if(petPalettes)
if(!currentOffer || !petPalette)
{
for(const paletteData of petPalettes)
{
if(paletteData.breed !== productData.type) continue;
const palettes: SellablePetPaletteData[] = [];
for(const palette of paletteData.palettes)
{
if(!palette.sellable) continue;
palettes.push(palette);
}
setSelectedPaletteIndex((palettes.length ? 0 : -1));
setSellablePalettes(palettes);
return;
}
setSelectedPaletteIndex(-1);
setSellablePalettes([]);
return;
}
setSelectedPaletteIndex(-1);
setSellablePalettes([]);
const palettes: SellablePetPaletteData[] = [];
SendMessageComposer(new GetSellablePetPalettesComposer(productData.type));
}, [ currentOffer, petPalettes ]);
for(const palette of petPalette.palettes)
{
if(!palette.sellable) continue;
palettes.push(palette);
}
setSelectedPaletteIndex(palettes.length ? 0 : -1);
setSellablePalettes(palettes);
}, [ currentOffer, petPalette ]);
useEffect(() =>
{
@@ -202,7 +190,10 @@ export const CatalogLayoutPetView: 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>
@@ -2,7 +2,7 @@ import { SelectClubGiftComposer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useMemo } from 'react';
import { LocalizeText, SendMessageComposer } from '../../../../../../api';
import { AutoGrid, Text } from '../../../../../../common';
import { useCatalog, useNotification, usePurse } from '../../../../../../hooks';
import { useClubGifts, useNotification, usePurse } from '../../../../../../hooks';
import { CatalogLayoutProps } from '../CatalogLayout.types';
import { VipGiftItem } from './VipGiftItemView';
@@ -11,8 +11,7 @@ let isSelectingGift = false;
export const CatalogLayoutVipGiftsView: FC<CatalogLayoutProps> = props =>
{
const { purse = null } = usePurse();
const { catalogOptions = null, setCatalogOptions = null } = useCatalog();
const { clubGifts = null } = catalogOptions;
const { data: clubGifts = null } = useClubGifts();
const { showConfirm = null } = useNotification();
const giftsAvailable = useCallback(() =>
@@ -36,34 +35,32 @@ export const CatalogLayoutVipGiftsView: FC<CatalogLayoutProps> = props =>
isSelectingGift = true;
// The server replies with a fresh ClubGiftInfoEvent after
// accepting the selection; useClubGifts subscribes to that
// event via useNitroEventInvalidator, so giftsAvailable
// refreshes from the authoritative source — no need to
// mutate the parser locally.
SendMessageComposer(new SelectClubGiftComposer(localizationId));
setCatalogOptions(prevValue =>
{
prevValue.clubGifts.giftsAvailable--;
return { ...prevValue };
});
setTimeout(() => isSelectingGift = false, 5000);
}, null);
}, [ setCatalogOptions, showConfirm ]);
}, [ showConfirm ]);
const sortGifts = useMemo(() =>
{
let gifts = clubGifts.offers.sort((a,b) =>
{
return clubGifts.getOfferExtraData(a.offerId).daysRequired - clubGifts.getOfferExtraData(b.offerId).daysRequired;
});
return gifts;
},[ clubGifts ]);
if(!clubGifts) return [];
return [ ...clubGifts.offers ].sort((a, b) =>
(clubGifts.getOfferExtraData(a.offerId).daysRequired - clubGifts.getOfferExtraData(b.offerId).daysRequired)
);
}, [ clubGifts ]);
return (
<>
<Text shrink truncate fontWeight="bold">{ giftsAvailable() }</Text>
<AutoGrid className="nitro-catalog-layout-vip-gifts-grid" columnCount={ 1 }>
{ (clubGifts.offers.length > 0) && sortGifts.map(offer => <VipGiftItem key={ offer.offerId } daysRequired={ clubGifts.getOfferExtraData(offer.offerId).daysRequired } isAvailable={ (clubGifts.getOfferExtraData(offer.offerId).isSelectable && (clubGifts.giftsAvailable > 0)) } offer={ offer } onSelect={ selectGift }/>) }
{ clubGifts && (clubGifts.offers.length > 0) && sortGifts.map(offer => <VipGiftItem key={ offer.offerId } daysRequired={ clubGifts.getOfferExtraData(offer.offerId).daysRequired } isAvailable={ (clubGifts.getOfferExtraData(offer.offerId).isSelectable && (clubGifts.giftsAvailable > 0)) } offer={ offer } onSelect={ selectGift }/>) }
</AutoGrid>
</>
);
@@ -1,6 +1,6 @@
import { FC } from 'react';
import { BaseProps, LayoutBadgeImageView } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData } from '../../../../../hooks';
interface CatalogAddOnBadgeWidgetViewProps extends BaseProps<HTMLDivElement>
{
@@ -10,7 +10,7 @@ interface CatalogAddOnBadgeWidgetViewProps extends BaseProps<HTMLDivElement>
export const CatalogAddOnBadgeWidgetView: FC<CatalogAddOnBadgeWidgetViewProps> = props =>
{
const { ...rest } = props;
const { currentOffer = null } = useCatalog();
const { currentOffer = null } = useCatalogData();
if(!currentOffer || !currentOffer.badgeCode || !currentOffer.badgeCode.length) return null;
@@ -1,7 +1,7 @@
import { StringDataType } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useState } from 'react';
import { AutoGrid, AutoGridProps, LayoutBadgeImageView, LayoutGridItem } from '../../../../../common';
import { useCatalog, useInventoryBadges } from '../../../../../hooks';
import { useCatalogData, useCatalogUiState, useInventoryBadges } from '../../../../../hooks';
const EXCLUDED_BADGE_CODES: string[] = [];
@@ -15,7 +15,8 @@ export const CatalogBadgeSelectorWidgetView: FC<CatalogBadgeSelectorWidgetViewPr
const { columnCount = 5, ...rest } = props;
const [ isVisible, setIsVisible ] = useState(false);
const [ currentBadgeCode, setCurrentBadgeCode ] = useState<string>(null);
const { currentOffer = null, setPurchaseOptions = null } = useCatalog();
const { currentOffer = null } = useCatalogData();
const { setPurchaseOptions = null } = useCatalogUiState();
const { badgeCodes = [], activate = null, deactivate = null } = useInventoryBadges();
const previewStuffData = useMemo(() =>
@@ -1,6 +1,6 @@
import { FC, useEffect, useRef } from 'react';
import { AutoGrid, AutoGridProps, LayoutGridItem } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData } from '../../../../../hooks';
interface CatalogBundleGridWidgetViewProps extends AutoGridProps
{
@@ -10,8 +10,8 @@ interface CatalogBundleGridWidgetViewProps extends AutoGridProps
export const CatalogBundleGridWidgetView: FC<CatalogBundleGridWidgetViewProps> = props =>
{
const { columnCount = 5, children = null, ...rest } = props;
const { currentOffer = null } = useCatalog();
const elementRef = useRef<HTMLDivElement>();
const { currentOffer = null } = useCatalogData();
const elementRef = useRef<HTMLDivElement>(null);
useEffect(() =>
{
@@ -1,9 +1,10 @@
import { FC, useEffect } from 'react';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData, useCatalogUiState } from '../../../../../hooks';
export const CatalogFirstProductSelectorWidgetView: FC<{}> = props =>
{
const { currentPage = null, setCurrentOffer = null } = useCatalog();
const { currentPage = null } = useCatalogData();
const { setCurrentOffer = null } = useCatalogUiState();
useEffect(() =>
{
@@ -1,7 +1,7 @@
import { StringDataType } from '@nitrots/nitro-renderer';
import { FC, useMemo } from 'react';
import { BaseProps, LayoutBadgeImageView } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData, useCatalogUiState } from '../../../../../hooks';
interface CatalogGuildBadgeWidgetViewProps extends BaseProps<HTMLDivElement>
{
@@ -11,7 +11,8 @@ interface CatalogGuildBadgeWidgetViewProps extends BaseProps<HTMLDivElement>
export const CatalogGuildBadgeWidgetView: FC<CatalogGuildBadgeWidgetViewProps> = props =>
{
const { ...rest } = props;
const { currentOffer = null, purchaseOptions = null } = useCatalog();
const { currentOffer = null } = useCatalogData();
const { purchaseOptions = null } = useCatalogUiState();
const { previewStuffData = null } = purchaseOptions;
const badgeCode = useMemo(() =>
@@ -1,14 +1,15 @@
import { CatalogGroupsComposer, StringDataType } from '@nitrots/nitro-renderer';
import { StringDataType } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useState } from 'react';
import { LocalizeText, SendMessageComposer } from '../../../../../api';
import { LocalizeText } from '../../../../../api';
import { Button, Flex } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData, useCatalogUiState, useUserGroups } from '../../../../../hooks';
export const CatalogGuildSelectorWidgetView: FC<{}> = props =>
{
const [ selectedGroupIndex, setSelectedGroupIndex ] = useState<number>(0);
const { currentOffer = null, catalogOptions = null, setPurchaseOptions = null } = useCatalog();
const { groups = null } = catalogOptions;
const { currentOffer = null } = useCatalogData();
const { setPurchaseOptions = null } = useCatalogUiState();
const { data: groups = null } = useUserGroups();
const previewStuffData = useMemo(() =>
{
@@ -41,11 +42,6 @@ export const CatalogGuildSelectorWidgetView: FC<{}> = props =>
});
}, [ currentOffer, previewStuffData, setPurchaseOptions ]);
useEffect(() =>
{
SendMessageComposer(new CatalogGroupsComposer());
}, []);
if(!groups || !groups.length)
{
return (
@@ -1,7 +1,7 @@
import { FC, useCallback, useEffect, useRef, useState } from 'react';
import { IPurchasableOffer } from '../../../../../api';
import { AutoGrid, AutoGridProps } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogActions, useCatalogData } from '../../../../../hooks';
import { useCatalogAdmin } from '../../../CatalogAdminContext';
import { CatalogGridOfferView } from '../common/CatalogGridOfferView';
@@ -13,10 +13,11 @@ interface CatalogItemGridWidgetViewProps extends AutoGridProps
export const CatalogItemGridWidgetView: FC<CatalogItemGridWidgetViewProps> = props =>
{
const { columnCount = 5, children = null, ...rest } = props;
const { currentOffer = null, currentPage = null, selectCatalogOffer = null } = useCatalog();
const { currentOffer = null, currentPage = null } = useCatalogData();
const { selectCatalogOffer = null } = useCatalogActions();
const catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
const elementRef = useRef<HTMLDivElement>();
const elementRef = useRef<HTMLDivElement>(null);
const [ dragIndex, setDragIndex ] = useState<number | null>(null);
const [ dropIndex, setDropIndex ] = useState<number | null>(null);
@@ -25,13 +26,13 @@ export const CatalogItemGridWidgetView: FC<CatalogItemGridWidgetViewProps> = pro
if(elementRef && elementRef.current) elementRef.current.scrollTop = 0;
}, [ currentPage ]);
if(!currentPage) return null;
const selectOffer = (offer: IPurchasableOffer) =>
{
selectCatalogOffer(offer);
};
// Drag-and-drop handlers — hooks MUST run unconditionally so the
// hook order stays stable when currentPage flips from null to a
// real value (the `if(!currentPage) return null` below would
// otherwise hide these from the first render and React would flag
// "Rendered more hooks than during the previous render"). Bodies
// are safe to evaluate pre-load: currentPage? optional chaining
// already guards the only access inside handleDrop.
const handleDragStart = useCallback((index: number) =>
{
setDragIndex(index);
@@ -67,6 +68,13 @@ export const CatalogItemGridWidgetView: FC<CatalogItemGridWidgetViewProps> = pro
setDropIndex(null);
}, []);
if(!currentPage) return null;
const selectOffer = (offer: IPurchasableOffer) =>
{
selectCatalogOffer(offer);
};
return (
<AutoGrid columnCount={ columnCount } innerRef={ elementRef } { ...rest }>
{ currentPage.offers && (currentPage.offers.length > 0) && currentPage.offers.map((offer, index) =>
@@ -1,11 +1,11 @@
import { FC } from 'react';
import { Offer } from '../../../../../api';
import { LayoutLimitedEditionCompletePlateView } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData } from '../../../../../hooks';
export const CatalogLimitedItemWidgetView: FC = props =>
{
const { currentOffer = null } = useCatalog();
const { currentOffer = null } = useCatalogData();
if(!currentOffer || (currentOffer.pricingModel !== Offer.PRICING_MODEL_SINGLE) || !currentOffer.product.isUniqueLimitedItem) return null;
@@ -2,7 +2,7 @@ import { FC } from 'react';
import { FaPlus } from 'react-icons/fa';
import { IPurchasableOffer } from '../../../../../api';
import { LayoutCurrencyIcon, Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogUiState } from '../../../../../hooks';
interface CatalogPriceDisplayWidgetViewProps
{
@@ -13,7 +13,7 @@ interface CatalogPriceDisplayWidgetViewProps
export const CatalogPriceDisplayWidgetView: FC<CatalogPriceDisplayWidgetViewProps> = props =>
{
const { offer = null, separator = false } = props;
const { purchaseOptions = null } = useCatalog();
const { purchaseOptions = null } = useCatalogUiState();
const { quantity = 1 } = purchaseOptions;
if(!offer) return null;
@@ -3,7 +3,7 @@ import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { BuilderFurniPlaceableStatus, CatalogPurchaseState, CatalogType, DispatchUiEvent, GetClubMemberLevel, LocalStorageKeys, LocalizeText, NotificationBubbleType, Offer, ProductTypeEnum, SendMessageComposer } from '../../../../../api';
import { Button, LayoutLoadingSpinnerView, Text } from '../../../../../common';
import { CatalogEvent, CatalogInitGiftEvent, CatalogPurchaseFailureEvent, CatalogPurchaseNotAllowedEvent, CatalogPurchaseSoldOutEvent, CatalogPurchasedEvent } from '../../../../../events';
import { useCatalog, useLocalStorage, useNotification, usePurse, useUiEvent } from '../../../../../hooks';
import { useCatalogActions, useCatalogData, useCatalogUiState, useLocalStorage, useNotification, usePurse, useUiEvent } from '../../../../../hooks';
interface CatalogPurchaseWidgetViewProps
{
@@ -20,7 +20,9 @@ export const CatalogPurchaseWidgetView: FC<CatalogPurchaseWidgetViewProps> = pro
const [ purchaseWillBeGift, setPurchaseWillBeGift ] = useState(false);
const [ purchaseState, setPurchaseState ] = useState(CatalogPurchaseState.NONE);
const [ catalogSkipPurchaseConfirmation, setCatalogSkipPurchaseConfirmation ] = useLocalStorage(LocalStorageKeys.CATALOG_SKIP_PURCHASE_CONFIRMATION, false);
const { currentOffer = null, currentPage = null, currentType = CatalogType.NORMAL, purchaseOptions = null, setPurchaseOptions = null, requestOfferToMover = null, setCatalogPlaceMultipleObjects = null, getBuilderFurniPlaceableStatus = null } = useCatalog();
const { currentOffer = null, currentPage = null } = useCatalogData();
const { currentType = CatalogType.NORMAL, purchaseOptions = null, setPurchaseOptions = null, setCatalogPlaceMultipleObjects = null } = useCatalogUiState();
const { requestOfferToMover = null, getBuilderFurniPlaceableStatus = null, getNodesByOfferId = null } = useCatalogActions();
const { getCurrencyAmount = null } = usePurse();
const { showSingleBubble = null } = useNotification();
@@ -89,7 +91,10 @@ export const CatalogPurchaseWidgetView: FC<CatalogPurchaseWidgetViewProps> = pro
isPurchasingCatalogItem = true;
setPurchaseState(CatalogPurchaseState.PURCHASE);
setTimeout(() => { isPurchasingCatalogItem = false; }, 10000);
setTimeout(() =>
{
isPurchasingCatalogItem = false;
}, 10000);
if(purchaseCallback)
{
@@ -100,12 +105,11 @@ export const CatalogPurchaseWidgetView: FC<CatalogPurchaseWidgetViewProps> = pro
let pageId = currentOffer.page.pageId;
// if(pageId === -1)
// {
// const nodes = getNodesByOfferId(currentOffer.offerId);
// if(nodes) pageId = nodes[0].pageId;
// }
if(pageId === -1 && getNodesByOfferId)
{
const nodes = getNodesByOfferId(currentOffer.offerId);
if(nodes && nodes.length) pageId = nodes[0].pageId;
}
SendMessageComposer(new PurchaseFromCatalogComposer(pageId, currentOffer.offerId, purchaseOptions.extraData, purchaseOptions.quantity));
};
@@ -132,15 +136,19 @@ export const CatalogPurchaseWidgetView: FC<CatalogPurchaseWidgetViewProps> = pro
};
}, [ purchaseState ]);
if(!currentOffer) return null;
// Builders-club state — derived + hooks MUST run unconditionally on
// every render so the hook order stays stable even when currentOffer
// is null (the `if(!currentOffer) return null` below would otherwise
// hide the useMemo/useEffect block from the first render and React
// would flag "Rendered more hooks than during the previous render").
const isBuildersClubOffer = (currentType === CatalogType.BUILDER);
const isBuildersClubPlaceable = isBuildersClubOffer
&& !!currentOffer
&& !!currentOffer.product
&& ((currentOffer.product.productType === ProductTypeEnum.FLOOR) || (currentOffer.product.productType === ProductTypeEnum.WALL));
const builderPlaceableStatus = useMemo(() =>
{
if(!isBuildersClubPlaceable || !getBuilderFurniPlaceableStatus) return BuilderFurniPlaceableStatus.OKAY;
if(!isBuildersClubPlaceable || !getBuilderFurniPlaceableStatus || !currentOffer) return BuilderFurniPlaceableStatus.OKAY;
return getBuilderFurniPlaceableStatus(currentOffer);
}, [ currentOffer, getBuilderFurniPlaceableStatus, isBuildersClubPlaceable, builderPlaceableRefreshTick ]);
@@ -159,6 +167,8 @@ export const CatalogPurchaseWidgetView: FC<CatalogPurchaseWidgetViewProps> = pro
return () => clearInterval(interval);
}, [ isBuildersClubPlaceable ]);
if(!currentOffer) return null;
const PurchaseButton = () =>
{
if(isBuildersClubPlaceable)
@@ -1,10 +1,10 @@
import { FC } from 'react';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData } from '../../../../../hooks';
import { CatalogPriceDisplayWidgetView } from './CatalogPriceDisplayWidgetView';
export const CatalogSimplePriceWidgetView: FC<{}> = props =>
{
const { currentOffer = null } = useCatalog();
const { currentOffer = null } = useCatalogData();
return (
<div className="flex items-center bg-muted p-1 rounded gap-1">
@@ -1,7 +1,7 @@
import { FC, useEffect, useRef, useState } from 'react';
import { IPurchasableOffer, LocalizeText, Offer, ProductTypeEnum } from '../../../../../api';
import { AutoGrid, AutoGridProps, Button } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData, useCatalogUiState } from '../../../../../hooks';
import { CatalogGridOfferView } from '../common/CatalogGridOfferView';
interface CatalogSpacesWidgetViewProps extends AutoGridProps
@@ -17,8 +17,9 @@ export const CatalogSpacesWidgetView: FC<CatalogSpacesWidgetViewProps> = props =
const [ groupedOffers, setGroupedOffers ] = useState<IPurchasableOffer[][]>(null);
const [ selectedGroupIndex, setSelectedGroupIndex ] = useState(-1);
const [ selectedOfferForGroup, setSelectedOfferForGroup ] = useState<IPurchasableOffer[]>(null);
const { currentPage = null, currentOffer = null, setCurrentOffer = null, setPurchaseOptions = null } = useCatalog();
const elementRef = useRef<HTMLDivElement>();
const { currentPage = null, currentOffer = null } = useCatalogData();
const { setCurrentOffer = null, setPurchaseOptions = null } = useCatalogUiState();
const elementRef = useRef<HTMLDivElement>(null);
const setSelectedOffer = (offer: IPurchasableOffer) =>
{
@@ -1,14 +1,15 @@
import { FC } from 'react';
import { FaMinus, FaPlus } from 'react-icons/fa';
import { LocalizeText } from '../../../../../api';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData, useCatalogUiState } from '../../../../../hooks';
const MIN_VALUE: number = 1;
const MAX_VALUE: number = 99;
export const CatalogSpinnerWidgetView: FC<{}> = props =>
{
const { currentOffer = null, purchaseOptions = null, setPurchaseOptions = null } = useCatalog();
const { currentOffer = null } = useCatalogData();
const { purchaseOptions = null, setPurchaseOptions = null } = useCatalogUiState();
const { quantity = 1 } = purchaseOptions;
const updateQuantity = (value: number) =>
@@ -1,6 +1,6 @@
import { FC } from 'react';
import { Column, ColumnProps } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData } from '../../../../../hooks';
import { CatalogPriceDisplayWidgetView } from './CatalogPriceDisplayWidgetView';
interface CatalogSimplePriceWidgetViewProps extends ColumnProps
@@ -10,7 +10,7 @@ interface CatalogSimplePriceWidgetViewProps extends ColumnProps
export const CatalogTotalPriceWidget: FC<CatalogSimplePriceWidgetViewProps> = props =>
{
const { gap = 1, ...rest } = props;
const { currentOffer = null } = useCatalog();
const { currentOffer = null } = useCatalogData();
return (
<Column gap={ gap } { ...rest }>
@@ -2,11 +2,12 @@ import { GetAvatarRenderManager, GetSessionDataManager, Vector3d } from '@nitrot
import { FC, useEffect } from 'react';
import { BuildPurchasableClothingFigure, FurniCategory, Offer, ProductTypeEnum } from '../../../../../api';
import { AutoGrid, Column, LayoutGridItem, LayoutRoomPreviewerView } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData, useCatalogUiState } from '../../../../../hooks';
export const CatalogViewProductWidgetView: FC<{}> = props =>
{
const { currentOffer = null, roomPreviewer = null, purchaseOptions = null } = useCatalog();
const { currentOffer = null, roomPreviewer = null } = useCatalogData();
const { purchaseOptions = null } = useCatalogUiState();
const { previewStuffData = null } = purchaseOptions;
useEffect(() =>
@@ -1,32 +1,28 @@
import { GetTargetedOfferComposer, TargetedOfferData, TargetedOfferEvent } from '@nitrots/nitro-renderer';
import { useEffect, useState } from 'react';
import { SendMessageComposer } from '../../../../api';
import { useMessageEvent } from '../../../../hooks';
import { useState } from 'react';
import { useNitroQuery } from '../../../../api/nitro-query';
import { OfferBubbleView } from './OfferBubbleView';
import { OfferWindowView } from './OfferWindowView';
export const OfferView = () =>
{
const [ offer, setOffer ] = useState<TargetedOfferData>(null);
const [ opened, setOpened ] = useState<boolean>(false);
useMessageEvent<TargetedOfferEvent>(TargetedOfferEvent, evt =>
{
let parser = evt.getParser();
if(!parser) return;
setOffer(parser.data);
const { data: offer } = useNitroQuery<TargetedOfferEvent, TargetedOfferData>({
key: [ 'nitro', 'catalog', 'targeted-offer' ],
request: () => new GetTargetedOfferComposer(),
parser: TargetedOfferEvent,
select: evt => evt.getParser()?.data ?? null,
staleTime: Infinity
});
useEffect(() =>
{
SendMessageComposer(new GetTargetedOfferComposer());
}, []);
const [ opened, setOpened ] = useState<boolean>(false);
if(!offer) return;
if(!offer) return null;
return <>
{ opened ? <OfferWindowView offer={ offer } setOpen={ setOpened } /> : <OfferBubbleView offer={ offer } setOpen={ setOpened } /> }
</>;
return (
<>
{ opened
? <OfferWindowView offer={ offer } setOpen={ setOpened } />
: <OfferBubbleView offer={ offer } setOpen={ setOpened } /> }
</>
);
};
+30 -20
View File
@@ -5,54 +5,64 @@ import { Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text }
import { useChatHistory, useOnClickChat } from '../../hooks';
import { NitroInput } from '../../layout';
export const ChatHistoryView: FC<{}> = props => {
export const ChatHistoryView: FC<{}> = props =>
{
const [isVisible, setIsVisible] = useState(false);
const [searchText, setSearchText] = useState<string>('');
const {chatHistory = []} = useChatHistory();
const { chatHistory = [] } = useChatHistory();
const { onClickChat } = useOnClickChat();
const elementRef = useRef<HTMLDivElement>(null);
const isFirstRender = useRef(true);
const prevChatLength = useRef<number>(0);
const filteredChatHistory = useMemo(() => {
const filteredChatHistory = useMemo(() =>
{
let result = chatHistory;
if (searchText.length > 0) {
if (searchText.length > 0)
{
const text = searchText.toLowerCase();
result = chatHistory.filter(entry =>
(entry.message && entry.message.toLowerCase().includes(text)) ||
result = chatHistory.filter(entry =>
(entry.message && entry.message.toLowerCase().includes(text)) ||
(entry.name && entry.name.toLowerCase().includes(text))
);
}
return [...result];
}, [chatHistory, searchText]);
useEffect(() => {
useEffect(() =>
{
if (!elementRef.current || !isVisible) return;
const element = elementRef.current;
const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
const isAtBottom = maxScrollTop === 0 || Math.abs(element.scrollTop - maxScrollTop) <= 50;
if (isFirstRender.current) {
if (isFirstRender.current)
{
element.scrollTo({ top: element.scrollHeight, behavior: 'smooth' });
isFirstRender.current = false;
} else if (filteredChatHistory.length > prevChatLength.current) {
}
else if (filteredChatHistory.length > prevChatLength.current)
{
element.scrollTo({ top: element.scrollHeight, behavior: 'smooth' });
}
prevChatLength.current = filteredChatHistory.length;
}, [filteredChatHistory, isVisible]);
useEffect(() => {
useEffect(() =>
{
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) => {
linkReceived: (url: string) =>
{
const parts = url.split('/');
if (parts.length < 2) return;
switch (parts[1]) {
switch (parts[1])
{
case 'show':
setIsVisible(true);
return;
@@ -84,18 +94,18 @@ export const ChatHistoryView: FC<{}> = props => {
<Flex key={index} alignItems="center" className="p-1" gap={2}>
<Text variant="gray">{row.timestamp}</Text>
{row.type === ChatEntryType.TYPE_CHAT && (
<div className="bubble-container" style={{position: 'relative', display: 'inline-flex', alignItems: 'center'}}>
<div
className={`chat-bubble bubble-${row.style} type-${row.chatType}`}
<div className="bubble-container" style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}>
<div
className={`chat-bubble bubble-${row.style} type-${row.chatType}`}
style={{ maxWidth: '100%', backgroundColor: row.style === 0 ? row.color : 'transparent', position: 'relative', zIndex: 1 }}>
<div className="user-container">
{row.imageUrl && row.imageUrl.length > 0 && (
<div className="user-image" style={{backgroundImage: `url(${row.imageUrl})`}} />
<div className="user-image" style={{ backgroundImage: `url(${row.imageUrl})` }} />
)}
</div>
<div className="chat-content">
<b className="mr-1 username" dangerouslySetInnerHTML={{__html: `${row.name}: `}} />
<span className="message" dangerouslySetInnerHTML={{__html: `${row.message}`}} onClick={ onClickChat } />
<b className="mr-1 username" dangerouslySetInnerHTML={{ __html: `${row.name}: ` }} />
<span className="message" dangerouslySetInnerHTML={{ __html: `${row.message}` }} onClick={ onClickChat } />
</div>
</div>
</div>
@@ -367,38 +367,38 @@ export const CustomizeNickIconView: FC<{}> = () =>
{ activePrefixSubTab === 'library' &&
<>
<div className="rounded border border-black/10 bg-black/5 p-2 text-[11px] leading-4">
<div className="rounded border border-black/10 bg-black/5 p-2 text-[11px] leading-4">
Choose a preset or custom prefix for your bubble identity.
</div>
<div className="grid grid-cols-2 gap-2">
{ combinedPrefixes.map(item => (
<div key={ `${ item.catalogPrefixId || 'custom' }-${ item.ownedPrefixId || item.id }` } className={ `relative flex min-h-[96px] flex-col gap-2 rounded border p-2.5 ${ item.active ? 'border-[#1e7295] bg-[#dff3fb]' : 'border-black/10 bg-black/5' }` }>
{ item.active && <span className="absolute right-1 top-1 rounded bg-[#1e7295] px-1.5 py-0.5 text-[9px] font-bold uppercase text-white">Active</span> }
<UserIdentityView
displayOrder={ displayOrder }
nickIcon={ activeIcon?.iconKey || '' }
prefixColor={ item.color }
prefixEffect={ item.effect }
prefixFont={ item.font || '' }
prefixIcon={ item.icon }
prefixText={ item.text }
username="Username" />
<div className="flex flex-col gap-1 text-[11px]">
<span>{ item.owned ? (item.active ? 'Owned - Active' : 'Owned') : 'Locked' }</span>
<span className="truncate">{ item.displayName || item.text }{ item.isCustom ? ' - Custom' : '' }</span>
<span className="inline-flex items-center gap-1">
<LayoutCurrencyIcon type={ item.pointsType } />
{ item.points }
</span>
</div>
<Button disabled={ isLoading } onClick={ () => handleCombinedPrefixAction(item) }>
{ !item.owned && 'Buy' }
{ item.owned && !item.active && 'Activate' }
{ item.owned && item.active && 'Deactivate' }
</Button>
</div>
)) }
</div>
<div className="grid grid-cols-2 gap-2">
{ combinedPrefixes.map(item => (
<div key={ `${ item.catalogPrefixId || 'custom' }-${ item.ownedPrefixId || item.id }` } className={ `relative flex min-h-[96px] flex-col gap-2 rounded border p-2.5 ${ item.active ? 'border-[#1e7295] bg-[#dff3fb]' : 'border-black/10 bg-black/5' }` }>
{ item.active && <span className="absolute right-1 top-1 rounded bg-[#1e7295] px-1.5 py-0.5 text-[9px] font-bold uppercase text-white">Active</span> }
<UserIdentityView
displayOrder={ displayOrder }
nickIcon={ activeIcon?.iconKey || '' }
prefixColor={ item.color }
prefixEffect={ item.effect }
prefixFont={ item.font || '' }
prefixIcon={ item.icon }
prefixText={ item.text }
username="Username" />
<div className="flex flex-col gap-1 text-[11px]">
<span>{ item.owned ? (item.active ? 'Owned - Active' : 'Owned') : 'Locked' }</span>
<span className="truncate">{ item.displayName || item.text }{ item.isCustom ? ' - Custom' : '' }</span>
<span className="inline-flex items-center gap-1">
<LayoutCurrencyIcon type={ item.pointsType } />
{ item.points }
</span>
</div>
<Button disabled={ isLoading } onClick={ () => handleCombinedPrefixAction(item) }>
{ !item.owned && 'Buy' }
{ item.owned && !item.active && 'Activate' }
{ item.owned && item.active && 'Deactivate' }
</Button>
</div>
)) }
</div>
</> }
{ activePrefixSubTab === 'custom' &&
@@ -430,14 +430,14 @@ export const CustomizeNickIconView: FC<{}> = () =>
</div>
<div className="grid grid-cols-6 gap-2">
{ PRESET_COLORS.map(color => (
<button
key={ color }
className={ `flex h-[28px] items-center justify-center rounded border text-[10px] font-bold uppercase ${ customPrefixColor === color ? 'border-[#1e7295] ring-1 ring-[#1e7295]' : 'border-black/10' }` }
style={ { backgroundColor: color } }
type="button"
onClick={ () => setCustomPrefixColor(color) }>
{ customPrefixColor === color ? 'ON' : '' }
</button>
<button
key={ color }
className={ `flex h-[28px] items-center justify-center rounded border text-[10px] font-bold uppercase ${ customPrefixColor === color ? 'border-[#1e7295] ring-1 ring-[#1e7295]' : 'border-black/10' }` }
style={ { backgroundColor: color } }
type="button"
onClick={ () => setCustomPrefixColor(color) }>
{ customPrefixColor === color ? 'ON' : '' }
</button>
)) }
</div>
</div>
@@ -572,7 +572,10 @@ export const CustomizeNickIconView: FC<{}> = () =>
<Picker
data={ data }
locale="en"
onEmojiSelect={ (emoji: { native: string }) => { setCustomPrefixIcon(emoji.native); setShowEmojiPicker(false); } }
onEmojiSelect={ (emoji: { native: string }) =>
{
setCustomPrefixIcon(emoji.native); setShowEmojiPicker(false);
} }
previewPosition="none"
set="native"
theme="dark" />
@@ -1,4 +1,4 @@
import { createContext, Dispatch, FC, ProviderProps, SetStateAction, useContext } from 'react';
import { createContext, Dispatch, FC, SetStateAction, useContext } from 'react';
import { IFloorplanSettings } from '@nitrots/nitro-renderer';
import { IVisualizationSettings } from '@nitrots/nitro-renderer';
@@ -29,6 +29,6 @@ const FloorplanEditorContext = createContext<IFloorplanEditorContext>({
areaInfo: { total: 0, walkable: 0 }
});
export const FloorplanEditorContextProvider: FC<ProviderProps<IFloorplanEditorContext>> = props => <FloorplanEditorContext.Provider { ...props } />;
export const FloorplanEditorContextProvider: FC<{ value: IFloorplanEditorContext; children?: React.ReactNode }> = props => <FloorplanEditorContext { ...props } />;
export const useFloorplanEditorContext = () => useContext(FloorplanEditorContext);
@@ -28,7 +28,7 @@ export const FloorplanImportExportView: FC<FloorplanImportExportViewProps> = pro
convertNumbersForSaving(originalFloorplanSettings.thicknessFloor),
originalFloorplanSettings.wallHeight - 1
));
}
};
useEffect(() =>
{
@@ -52,4 +52,4 @@ export const FloorplanImportExportView: FC<FloorplanImportExportViewProps> = pro
</NitroCardContentView>
</NitroCardView>
);
}
};
+2 -1
View File
@@ -7,7 +7,8 @@ import { FriendsMessengerView } from './views/messenger/FriendsMessengerView';
const FRIEND_BAR_TARGET_IDS = [ 'toolbar-friend-bar-container-desktop' ];
export const FriendsView: FC<{}> = props => {
export const FriendsView: FC<{}> = props =>
{
const { settings = null, onlineFriends = [], requests = [] } = useFriends();
const [ portalTarget, setPortalTarget ] = useState<HTMLElement | null>(null);
@@ -5,17 +5,21 @@ import { LayoutAvatarImageView, LayoutBadgeImageView } from '../../../../common'
import { useFriends } from '../../../../hooks';
import { motion, AnimatePresence } from 'framer-motion';
export const FriendBarItemView: FC<{ friend: MessengerFriend }> = props => {
export const FriendBarItemView: FC<{ friend: MessengerFriend }> = props =>
{
const { friend = null } = props;
const [isVisible, setVisible] = useState(false);
const { followFriend = null } = useFriends();
const elementRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const onClick = (event: MouseEvent) => {
useEffect(() =>
{
const onClick = (event: MouseEvent) =>
{
const element = elementRef.current;
if (!element) return;
if ((event.target !== element) && !element.contains((event.target as Node))) {
if ((event.target !== element) && !element.contains((event.target as Node)))
{
setVisible(false);
}
};
@@ -23,7 +27,8 @@ export const FriendBarItemView: FC<{ friend: MessengerFriend }> = props => {
return () => document.removeEventListener(MouseEventType.MOUSE_CLICK, onClick);
}, []);
if (!friend) {
if (!friend)
{
return (
<div ref={elementRef} className="relative">
<motion.button
@@ -35,21 +40,24 @@ export const FriendBarItemView: FC<{ friend: MessengerFriend }> = props => {
<div className="absolute left-[6px] top-1/2 h-[24px] w-[24px] -translate-y-1/2 bg-[url('@/assets/images/toolbar/friend-search.png')] bg-contain bg-center bg-no-repeat pointer-events-none" />
<div className="truncate text-[0.83rem]">{LocalizeText('friend.bar.find.title')}</div>
</motion.button>
<AnimatePresence>
{isVisible && (
<motion.div
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
className="absolute bottom-[calc(100%+12px)] left-1/2 -translate-x-1/2 tbme-panel whitespace-nowrap z-[80] flex flex-col items-center gap-2 pointer-events-auto min-w-[170px]"
>
<div className="text-white text-[13px] drop-shadow-[1px_1px_0_#000]">{LocalizeText('friend.bar.find.title')}</div>
<div className="text-white/80 text-xs px-2">{LocalizeText('friend.bar.find.text')}</div>
<button
<button
className="px-3 py-1 bg-black/40 hover:bg-black/60 border border-white/10 rounded-lg text-white text-[11px] transition-colors cursor-pointer mt-1"
onClick={event => { event.stopPropagation(); SendMessageComposer(new FindNewFriendsMessageComposer()); setVisible(false); }}
onClick={event =>
{
event.stopPropagation(); SendMessageComposer(new FindNewFriendsMessageComposer()); setVisible(false);
}}
>
{LocalizeText('friend.bar.find.button')}
</button>
@@ -88,19 +96,28 @@ export const FriendBarItemView: FC<{ friend: MessengerFriend }> = props => {
<AnimatePresence>
{isVisible && (
<motion.div
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
className="absolute bottom-[calc(100%+12px)] left-1/2 -translate-x-1/2 tbme-panel flex flex-col items-center gap-2 z-[80] pointer-events-auto min-w-[110px]"
>
<div className="text-white font-bold text-[13px] drop-shadow-[1px_1px_0_#000] truncate max-w-[120px] px-1">{friend.name}</div>
<div className="flex justify-center gap-3 px-2">
<div className="cursor-pointer tbme-icon nitro-friends-spritesheet icon-friendbar-chat hover:-translate-y-1 transition-transform" onClick={event => { event.stopPropagation(); OpenMessengerChat(friend.id); setVisible(false); }} />
<div className="cursor-pointer tbme-icon nitro-friends-spritesheet icon-friendbar-chat hover:-translate-y-1 transition-transform" onClick={event =>
{
event.stopPropagation(); OpenMessengerChat(friend.id); setVisible(false);
}} />
{friend.online &&
<div className="cursor-pointer tbme-icon nitro-friends-spritesheet icon-friendbar-visit hover:-translate-y-1 transition-transform" onClick={event => { event.stopPropagation(); followFriend(friend); setVisible(false); }} />}
<div className="cursor-pointer tbme-icon nitro-friends-spritesheet icon-profile hover:-translate-y-1 transition-transform" onClick={event => { event.stopPropagation(); GetUserProfile(friend.id); setVisible(false); }} />
<div className="cursor-pointer tbme-icon nitro-friends-spritesheet icon-friendbar-visit hover:-translate-y-1 transition-transform" onClick={event =>
{
event.stopPropagation(); followFriend(friend); setVisible(false);
}} />}
<div className="cursor-pointer tbme-icon nitro-friends-spritesheet icon-profile hover:-translate-y-1 transition-transform" onClick={event =>
{
event.stopPropagation(); GetUserProfile(friend.id); setVisible(false);
}} />
</div>
</motion.div>
)}
@@ -2,24 +2,25 @@ import { FC, useRef, useState } from 'react';
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
import { LocalizeText, MessengerFriend } from '../../../../api';
import { FriendBarItemView } from './FriendBarItemView';
import { motion, AnimatePresence } from 'framer-motion';
import { motion, AnimatePresence, Variants } from 'framer-motion';
const MAX_DISPLAY_COUNT = 3;
// Mirrored from Toolbar to keep physics identical
const containerVariants = {
const containerVariants: Variants = {
hidden: {},
visible: { transition: { staggerChildren: 0.05 } },
exit: { transition: { staggerChildren: 0.03, staggerDirection: -1 as const } },
exit: { transition: { staggerChildren: 0.03, staggerDirection: -1 } },
};
const itemVariants = {
const itemVariants: Variants = {
hidden: { opacity: 0, y: 10, scale: 0.8 },
visible: { opacity: 1, y: 0, scale: 1, transition: { type: 'spring', stiffness: 400, damping: 22 } },
exit: { opacity: 0, y: 6, scale: 0.85, transition: { duration: 0.1 } },
};
export const FriendBarView: FC<{ onlineFriends: MessengerFriend[]; requestsCount?: number }> = props => {
export const FriendBarView: FC<{ onlineFriends: MessengerFriend[]; requestsCount?: number }> = props =>
{
const { onlineFriends = [], requestsCount = 0 } = props;
const [ indexOffset, setIndexOffset ] = useState(0);
const elementRef = useRef<HTMLDivElement>(null);
@@ -27,8 +28,8 @@ export const FriendBarView: FC<{ onlineFriends: MessengerFriend[]; requestsCount
const visibleFriends = onlineFriends.slice(indexOffset, (indexOffset + MAX_DISPLAY_COUNT));
return (
<motion.div
ref={elementRef}
<motion.div
ref={elementRef}
className="flex h-[40px] items-center gap-[6px] px-[2px] py-[3px]"
variants={containerVariants}
initial="hidden"
@@ -42,9 +43,12 @@ export const FriendBarView: FC<{ onlineFriends: MessengerFriend[]; requestsCount
</div>
</motion.div> }
<motion.div variants={itemVariants}>
<div
<div
className={ `flex h-[34px] w-[20px] items-center justify-center text-white/80 transition-all ${ (!hasScrollableFriends || (indexOffset <= 0)) ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer hover:text-white active:scale-95' }` }
onClick={ () => { if(indexOffset > 0) setIndexOffset(indexOffset - 1); } }
onClick={ () =>
{
if(indexOffset > 0) setIndexOffset(indexOffset - 1);
} }
>
<FaChevronLeft className="text-white/70 text-sm drop-shadow-[1px_1px_0_#000]" />
</div>
@@ -89,9 +93,12 @@ export const FriendBarView: FC<{ onlineFriends: MessengerFriend[]; requestsCount
</AnimatePresence>
<motion.div variants={itemVariants}>
<div
<div
className={ `flex h-[34px] w-[20px] items-center justify-center text-white/80 transition-all ${ (!hasScrollableFriends || !((onlineFriends.length > MAX_DISPLAY_COUNT) && ((indexOffset + MAX_DISPLAY_COUNT) <= (onlineFriends.length - 1)))) ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer hover:text-white active:scale-95' }` }
onClick={ () => { if((onlineFriends.length > MAX_DISPLAY_COUNT) && ((indexOffset + MAX_DISPLAY_COUNT) <= (onlineFriends.length - 1))) setIndexOffset(indexOffset + 1); } }
onClick={ () =>
{
if((onlineFriends.length > MAX_DISPLAY_COUNT) && ((indexOffset + MAX_DISPLAY_COUNT) <= (onlineFriends.length - 1))) setIndexOffset(indexOffset + 1);
} }
>
<FaChevronRight className="text-white/70 text-sm drop-shadow-[1px_1px_0_#000]" />
</div>
@@ -148,7 +148,10 @@ export const FriendsListView: FC<{}> = props =>
<NitroCardAccordionView fullHeight overflow="hidden">
<NitroCardAccordionSetView className="friends-list-section" headerText={ LocalizeText('friendlist.friends') + ` (${ onlineFriends.length })` } isExpanded={ true }>
<Flex className="friends-list-toolbar px-2 py-1" justifyContent="end">
<span className="friends-list-toolbar-link" onClick={ event => { event.stopPropagation(); toggleSelectFriends(onlineFriends.map(friend => friend.id)); } }>
<span className="friends-list-toolbar-link" onClick={ event =>
{
event.stopPropagation(); toggleSelectFriends(onlineFriends.map(friend => friend.id));
} }>
{ onlineFriends.length && onlineFriends.every(friend => (selectedFriendsIds.indexOf(friend.id) >= 0))
? LocalizeText('friendlist.unselect_all')
: LocalizeText('friendlist.select_all') }
@@ -158,7 +161,10 @@ export const FriendsListView: FC<{}> = props =>
</NitroCardAccordionSetView>
<NitroCardAccordionSetView headerText={ LocalizeText('friendlist.friends.offlinecaption') + ` (${ offlineFriends.length })` }>
<Flex className="friends-list-toolbar px-2 py-1" justifyContent="end">
<span className="friends-list-toolbar-link" onClick={ event => { event.stopPropagation(); toggleSelectFriends(offlineFriends.map(friend => friend.id)); } }>
<span className="friends-list-toolbar-link" onClick={ event =>
{
event.stopPropagation(); toggleSelectFriends(offlineFriends.map(friend => friend.id));
} }>
{ offlineFriends.length && offlineFriends.every(friend => (selectedFriendsIds.indexOf(friend.id) >= 0))
? LocalizeText('friendlist.unselect_all')
: LocalizeText('friendlist.select_all') }
@@ -14,7 +14,7 @@ export const FriendsMessengerView: FC<{}> = props =>
const { visibleThreads = [], activeThread = null, getMessageThread = null, sendMessage = null, setActiveThreadId = null, closeThread = null } = useMessenger();
const { report = null } = useHelp();
const { settings, translateOutgoing } = useTranslation();
const messagesBox = useRef<HTMLDivElement>();
const messagesBox = useRef<HTMLDivElement>(null);
const followFriend = () => (activeThread && activeThread.participant && SendMessageComposer(new FollowFriendMessageComposer(activeThread.participant.id)));
const openProfile = () => (activeThread && activeThread.participant && GetUserProfile(activeThread.participant.id));
@@ -1,7 +1,7 @@
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useState } from 'react';
import { GetSessionDataManager } from '../../api';
import { NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common';
import { useHasPermission } from '../../hooks';
import { useFurniEditor } from '../../hooks/furni-editor';
import { FurniEditorEditView } from './views/FurniEditorEditView';
import { FurniEditorSearchView } from './views/FurniEditorSearchView';
@@ -21,7 +21,7 @@ export const FurniEditorView: FC<{}> = () =>
searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions
} = useFurniEditor();
const isMod = GetSessionDataManager()?.isModerator;
const isMod = useHasPermission('acc_catalogfurni');
// Auto-switch to edit tab when an item is selected
useEffect(() =>
@@ -93,9 +93,9 @@ export const FurniEditorView: FC<{}> = () =>
loadBySpriteId(spriteId);
};
window.addEventListener('furni-editor:open', handler as EventListener);
window.addEventListener('furni-editor:open', handler);
return () => window.removeEventListener('furni-editor:open', handler as EventListener);
return () => window.removeEventListener('furni-editor:open', handler);
}, [ isMod, loadBySpriteId ]);
const handleSelect = useCallback((id: number) =>
@@ -1,4 +1,4 @@
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { FC, useCallback, useEffect, useEffectEvent, useMemo, useState } from 'react';
import { Button, Column, Flex, LayoutFurniIconImageView, Text } from '../../../common';
import { FurniItem } from '../../../hooks/furni-editor';
@@ -30,9 +30,11 @@ export const FurniEditorSearchView: FC<FurniEditorSearchViewProps> = props =>
const [ sortField, setSortField ] = useState<SortField>('id');
const [ sortDir, setSortDir ] = useState<SortDir>('asc');
const initialSearch = useEffectEvent(() => onSearch('', '', 1));
useEffect(() =>
{
onSearch('', '', 1);
initialSearch();
}, []);
const handleSearch = useCallback(() =>
@@ -7,7 +7,7 @@ export const GameStageView = () =>
{
const { gameURL, setGameURL } = useGameCenter();
const [ loadTimes, setLoadTimes ] = useState<number>(0);
const ref = useRef<HTMLDivElement>();
const ref = useRef<HTMLDivElement>(null);
useEffect(() =>
{
@@ -108,13 +108,14 @@ export const GroupCreatorView: FC<GroupCreatorViewProps> = props =>
setGroupData({
groupId: -1,
groupName: null,
groupDescription: null,
groupName: '',
groupDescription: '',
groupHomeroomId: -1,
groupState: 1,
groupCanMembersDecorate: true,
groupColors: null,
groupBadgeParts: null
groupHasForum: false,
groupColors: [],
groupBadgeParts: []
});
SendMessageComposer(new GroupBuyDataComposer());
@@ -58,7 +58,10 @@ export const GroupForumListView: FC<GroupForumListViewProps> = props =>
<select
className="form-select form-select-sm"
value={ listMode }
onChange={ e => { setListMode(parseInt(e.target.value)); setStartIndex(0); } }>
onChange={ e =>
{
setListMode(parseInt(e.target.value)); setStartIndex(0);
} }>
<option value={ 0 }>{ LocalizeText('groupforum.list.tab.most_active') }</option>
<option value={ 2 }>{ LocalizeText('groupforum.list.tab.my_forums') }</option>
</select>
@@ -89,7 +92,10 @@ export const GroupForumListView: FC<GroupForumListViewProps> = props =>
<Column className="flex-shrink-0 text-end min-w-[100px]" gap={ 0 }>
{ (forum.lastMessageAuthorId > 0) && <>
<Text small variant="muted">{ LocalizeText('messageboard.last.message') }</Text>
<Text small pointer underline onClick={ e => { e.stopPropagation(); GetUserProfile(forum.lastMessageAuthorId); } }>
<Text small pointer underline onClick={ e =>
{
e.stopPropagation(); GetUserProfile(forum.lastMessageAuthorId);
} }>
{ forum.lastMessageAuthorName }
</Text>
<Text small variant="muted">{ formatTimeAgo(forum.lastMessageTimeAsSecondsAgo) }</Text>
@@ -165,7 +165,10 @@ export const GroupForumThreadListView: FC<GroupForumThreadListViewProps> = props
</Flex>
<Flex gap={ 1 }>
<Text small variant="muted">{ LocalizeText('messageboard.started.by') }</Text>
<Text small pointer underline onClick={ e => { e.stopPropagation(); GetUserProfile(thread.authorId); } }>
<Text small pointer underline onClick={ e =>
{
e.stopPropagation(); GetUserProfile(thread.authorId);
} }>
{ thread.authorName }
</Text>
<Text small variant="muted">- { formatTimeAgo(thread.creationTimeAsSecondsAgo) }</Text>
@@ -182,7 +185,10 @@ export const GroupForumThreadListView: FC<GroupForumThreadListViewProps> = props
</Column> }
<Column className="flex-shrink-0 text-end min-w-[100px]" gap={ 0 }>
<Text small variant="muted">{ LocalizeText('messageboard.last.message') }</Text>
<Text small pointer underline onClick={ e => { e.stopPropagation(); GetUserProfile(thread.lastUserId); } }>
<Text small pointer underline onClick={ e =>
{
e.stopPropagation(); GetUserProfile(thread.lastUserId);
} }>
{ thread.lastUserName }
</Text>
<Text small variant="muted">{ formatTimeAgo(thread.lastCommentTime) }</Text>
@@ -101,7 +101,7 @@ export const GuideToolOngoingView: FC<GuideToolOngoingViewProps> = props =>
{ (isOwnChat(group.userId)) && GetSessionDataManager().userName }
{ (!isOwnChat(group.userId)) && userName }
</Text>
{ group.messages.map((chat, index) => <div key={ index } className={ classNames(chat.roomId ? 'text-break text-underline' : 'text-break', 'chat.roomId' && 'cursor-pointer') } onClick={ () => chat.roomId ? TryVisitRoom(chat.roomId) : null }>{ chat.message }</div>) }
{ group.messages.map((chat, index) => <div key={ index } className={ classNames(chat.roomId ? 'text-break text-underline' : 'text-break', chat.roomId && 'cursor-pointer') } onClick={ () => chat.roomId ? TryVisitRoom(chat.roomId) : null }>{ chat.message }</div>) }
</div>
{ (isOwnChat(group.userId)) &&
<div className="shrink-0 message-avatar">
+16 -16
View File
@@ -37,8 +37,8 @@ function getHourInTimezone(timezone: string): number
*/
function getTimeOfDay(hour: number): string
{
if(hour > 5 && hour <= 9) return 'morning';
if(hour > 9 && hour <= 16) return 'day';
if(hour > 5 && hour <= 9) return 'morning';
if(hour > 9 && hour <= 16) return 'day';
if(hour > 16 && hour <= 19) return 'sunset';
if(hour > 19 && hour <= 23) return 'evening';
@@ -64,8 +64,8 @@ export const HotelView: FC<{}> = props =>
return getTimeOfDay(hour);
}, [ timezone ]);
/**
/**
const timeOfDay = 'sunset';
For debuging the diff views
*/
@@ -73,9 +73,9 @@ export const HotelView: FC<{}> = props =>
const skyColor = SKY_COLORS[timeOfDay] ?? configBgColor ?? '#000';
const containerRef = useRef<HTMLDivElement>(null);
const isDragging = useRef(false);
const lastMouseX = useRef(0);
const lastMouseY = useRef(0);
const isDragging = useRef(false);
const lastMouseX = useRef(0);
const lastMouseY = useRef(0);
useEffect(() =>
{
@@ -85,7 +85,7 @@ export const HotelView: FC<{}> = props =>
const centerView = () =>
{
const viewportWidth = window.innerWidth;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight - 55;
const lobbyEl = container.querySelector<HTMLElement>('.nitro-hotel-view-lobby');
@@ -93,18 +93,18 @@ export const HotelView: FC<{}> = props =>
if(lobbyEl)
{
const containerRect = container.getBoundingClientRect();
const lobbyRect = lobbyEl.getBoundingClientRect();
const lobbyRect = lobbyEl.getBoundingClientRect();
const lobbyCenterX = (lobbyRect.left - containerRect.left) + container.scrollLeft + lobbyRect.width / 2;
const lobbyCenterY = (lobbyRect.top - containerRect.top) + container.scrollTop + lobbyRect.height / 2;
const lobbyCenterX = (lobbyRect.left - containerRect.left) + container.scrollLeft + lobbyRect.width / 2;
const lobbyCenterY = (lobbyRect.top - containerRect.top) + container.scrollTop + lobbyRect.height / 2;
container.scrollLeft = Math.max(0, lobbyCenterX - viewportWidth / 2);
container.scrollTop = Math.max(0, lobbyCenterY - viewportHeight / 2);
container.scrollLeft = Math.max(0, lobbyCenterX - viewportWidth / 2);
container.scrollTop = Math.max(0, lobbyCenterY - viewportHeight / 2);
}
else
{
container.scrollLeft = Math.max(0, (2600 - viewportWidth) / 2);
container.scrollTop = Math.max(0, (1425 - viewportHeight) / 2);
container.scrollLeft = Math.max(0, (2600 - viewportWidth) / 2);
container.scrollTop = Math.max(0, (1425 - viewportHeight) / 2);
}
};
@@ -141,7 +141,7 @@ export const HotelView: FC<{}> = props =>
if(containerRef.current)
{
containerRef.current.scrollLeft -= dx;
containerRef.current.scrollTop -= dy;
containerRef.current.scrollTop -= dy;
}
};
+2 -1
View File
@@ -5,7 +5,8 @@ import { Base } from '../../common';
export interface RoomWidgetViewProps {}
export const RoomWidgetView: FC<RoomWidgetViewProps> = props => {
export const RoomWidgetView: FC<RoomWidgetViewProps> = props =>
{
const poolId = GetConfigurationValue<string>('hotelview')['room.pool'];
const picnicId = GetConfigurationValue<string>('hotelview')['room.picnic'];
const rooftopId = GetConfigurationValue<string>('hotelview')['room.rooftop'];
@@ -126,7 +126,8 @@ export const InterfaceColorTabView: FC<{}> = () =>
setImportValue('');
setShowImport(false);
}
catch(e) {}
catch(e)
{}
}, [ importValue, updateSettings ]);
return (
@@ -107,8 +107,14 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
}
}, []);
useEffect(() => { refreshOwnCustomBadges(); }, [ refreshOwnCustomBadges ]);
useEffect(() => { ensureCustomBadgeTexts(); }, []);
useEffect(() =>
{
refreshOwnCustomBadges();
}, [ refreshOwnCustomBadges ]);
useEffect(() =>
{
ensureCustomBadgeTexts();
}, []);
const baseCodes = (filteredBadgeCodes !== null ? filteredBadgeCodes : badgeCodes);
const customCount = useMemo(() => baseCodes.filter(c => isCustomBadgeCode(c)).length, [ baseCodes ]);
@@ -138,7 +144,8 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
await refreshOwnCustomBadges();
refreshCustomBadgeTexts();
}
catch { /* error already surfaced server-side */ }
catch
{ /* error already surfaced server-side */ }
},
null, null, null,
LocalizeText('inventory.delete.confirm_delete.title')
+113 -13
View File
@@ -1,21 +1,77 @@
import { FC } from 'react';
import { GetConfiguration } from '@nitrots/nitro-renderer';
import { FC, useMemo } from 'react';
import loadingGif from '@/assets/images/loading/loading.gif';
import nitroV3Logo from '@/assets/images/notifications/nitro_v3.png';
import { Base, Column, Text } from '../../common';
interface LoadingViewProps {
isError?: boolean;
message?: string;
homeUrl?: string;
progress?: number;
currentTask?: string;
}
export const LoadingView: FC<LoadingViewProps> = props => {
const { isError = false, message = '', homeUrl = '' } = props;
const resolveConfigUrl = (key: string): string =>
{
try
{
const raw = GetConfiguration().getValue<string>(key, '');
if(!raw) return '';
const interpolated = GetConfiguration().interpolate(raw) || raw;
return interpolated;
}
catch
{
return '';
}
};
const resolveConfigString = (key: string, fallback = ''): string =>
{
try
{
const raw = GetConfiguration().getValue<string>(key, '');
if(!raw) return fallback;
return raw;
}
catch
{
return fallback;
}
};
export const LoadingView: FC<LoadingViewProps> = props =>
{
const { isError = false, message = '', homeUrl = '', progress, currentTask = '' } = props;
const customLogoUrl = useMemo(() => resolveConfigUrl('loading.logo.url'), []);
const customBackground = useMemo(() => resolveConfigString('loading.background', ''), []);
const progressBarColor = useMemo(() => resolveConfigString('loading.progress.color', 'linear-gradient(90deg,#4f8cff,#2563eb)'), []);
const clampedProgress = typeof progress === 'number' && Number.isFinite(progress)
? Math.max(0, Math.min(100, Math.round(progress)))
: null;
const backgroundStyle = customBackground
? { background: customBackground }
: undefined;
const backgroundClassName = customBackground
? 'fixed inset-0 z-[2147483000]'
: 'fixed inset-0 z-[2147483000] bg-[radial-gradient(#1d1a24,#003a6b)]';
return (
<Column fullHeight position="fixed" className="fixed inset-0 z-[2147483000] bg-[radial-gradient(#1d1a24,#003a6b)]">
<Column fullHeight position="fixed" className={ backgroundClassName } style={ backgroundStyle }>
<img
src={ nitroV3Logo }
alt="Nitro V3"
draggable={ false }
className="absolute top-5 left-0 z-2 w-37.5 h-auto select-none pointer-events-none"
/>
<Base fullHeight className="container h-100">
<Column fullHeight alignItems="center" justifyContent="center">
<Base className="absolute top-[20px] left-[20px] z-[2] w-[150px] h-[100px] bg-[url('@/assets/images/notifications/nitro_v3.png')] bg-no-repeat bg-left-top" />
{ isError && (message && message.length) ?
<Column alignItems="center" className="absolute bottom-[20px] left-1/2 z-[3] -translate-x-1/2 max-w-[80%]" gap={ 2 }>
<Text fontSizeCustom={ 20 } variant="white" className="text-center [text-shadow:0px_4px_4px_rgba(0,0,0,0.25)]">
@@ -31,15 +87,59 @@ export const LoadingView: FC<LoadingViewProps> = props => {
}
</Column>
:
<Column alignItems="center" justifyContent="center" gap={ 3 } className="z-[3]">
<img src={ loadingGif } alt="" draggable={ false } className="block w-auto h-auto select-none pointer-events-none" />
{ message && message.length ?
<Text fontSizeCustom={ 20 } variant="white" className="text-center [text-shadow:0px_4px_4px_rgba(0,0,0,0.25)]">
{ message }
</Text>
: null
<>
<Column alignItems="center" justifyContent="center" className="z-[3] absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
<img
src={ customLogoUrl || loadingGif }
alt=""
draggable={ false }
className="block w-auto h-auto max-w-[80vw] max-h-[40vh] select-none pointer-events-none"
/>
{ message && message.length ?
<Text fontSizeCustom={ 22 } variant="white" className="text-center mt-4 [text-shadow:0px_4px_4px_rgba(0,0,0,0.4)] tracking-wide">
{ message }
</Text>
: null
}
</Column>
{ clampedProgress !== null &&
<Column
alignItems="center"
gap={ 2 }
className="absolute bottom-[8vh] left-1/2 -translate-x-1/2 z-[4] w-[min(900px,90vw)]"
>
<Base
className="relative w-full h-8 rounded-full overflow-hidden border border-white/30 shadow-[0_8px_24px_rgba(0,0,0,0.45)]"
style={ { background: 'rgba(0,0,0,0.45)', backdropFilter: 'blur(4px)' } }
>
<Base
className="h-full rounded-full transition-[width] duration-300 ease-out"
style={ { width: `${ clampedProgress }%`, background: progressBarColor, boxShadow: '0 0 18px rgba(79,140,255,0.55)' } }
/>
<Base
className="absolute inset-0 flex items-center justify-center pointer-events-none"
style={ { fontFamily: '"Poppins","Segoe UI",system-ui,sans-serif', fontWeight: 700, fontSize: '16px', color: '#fff', letterSpacing: '0.08em', textShadow: '0 2px 4px rgba(0,0,0,0.6)' } }
>
{ clampedProgress }%
</Base>
</Base>
<Base
className="text-center"
style={ {
fontFamily: '"Poppins","Segoe UI",system-ui,sans-serif',
fontWeight: 500,
fontSize: '15px',
color: 'rgba(255,255,255,0.85)',
letterSpacing: '0.04em',
textShadow: '0 2px 4px rgba(0,0,0,0.5)',
minHeight: '22px'
} }
>
{ currentTask }
</Base>
</Column>
}
</Column>
</>
}
</Column>
</Base>
+350 -146
View File
@@ -1,5 +1,6 @@
import { AvatarScaleType, AvatarSetType, GetAvatarRenderManager, GetConfiguration, IAvatarImage } from '@nitrots/nitro-renderer';
import { FC, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FC, useActionState, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useFormStatus } from 'react-dom';
import { ClearRememberLogin, GetConfigurationValue, GetRememberLogin, StoreRememberLoginFromPayload, persistAccessTokenFromPayload } from '../../api';
import { configFileUrl } from '../../secure-assets';
import flagBr from '../../assets/images/flag_icon/flag_icon_br.png';
@@ -26,8 +27,12 @@ const interpolate = (value: string | null | undefined): string =>
let output = value;
try { output = GetConfiguration().interpolate(value) || value; }
catch {}
try
{
output = GetConfiguration().interpolate(value) || value;
}
catch
{}
return output.replace(/\$\{([^}]+)\}/g, (_, key: string) =>
{
@@ -44,7 +49,8 @@ const interpolate = (value: string | null | undefined): string =>
if(configValue) return configValue;
}
catch {}
catch
{}
try
{
@@ -52,7 +58,8 @@ const interpolate = (value: string | null | undefined): string =>
if(configValue) return configValue;
}
catch {}
catch
{}
return '';
});
@@ -99,13 +106,20 @@ const readLock = (): AttemptState =>
if(!raw) return { attempts: 0, firstAt: 0, lockedUntil: 0 };
return JSON.parse(raw);
}
catch { return { attempts: 0, firstAt: 0, lockedUntil: 0 }; }
catch
{
return { attempts: 0, firstAt: 0, lockedUntil: 0 };
}
};
const writeLock = (state: AttemptState) =>
{
try { sessionStorage.setItem(LOCK_KEY, JSON.stringify(state)); }
catch { }
try
{
sessionStorage.setItem(LOCK_KEY, JSON.stringify(state));
}
catch
{ }
};
const normalizeLanguageCode = (value: string): string =>
@@ -149,7 +163,8 @@ const readCachedLocale = (): LoginLocale =>
if(typeof settings.uiTextLanguage === 'string' && settings.uiTextLanguage.length) return resolveLoginLocale(settings.uiTextLanguage);
}
catch {}
catch
{}
return getBrowserLocale();
};
@@ -169,7 +184,22 @@ const applyLocaleSelection = (locale: LoginLocale): void =>
localStorage.setItem(CHAT_TRANSLATION_SETTINGS_KEY, JSON.stringify(nextSettings));
}
catch {}
catch
{}
};
const LoginSubmitButton: FC<{ isEntering: boolean; isLocked: boolean; loginPingingServer: boolean }> = ({ isEntering, isLocked, loginPingingServer }) =>
{
const { pending } = useFormStatus();
return (
<button
type="submit"
className="ok-button"
disabled={ pending || isEntering || isLocked }>
{ isEntering ? t('nitro.login.entering', 'Entering…') : (pending || loginPingingServer) ? t('nitro.login.server.checking', 'Checking…') : t('login.title', 'Log in') }
</button>
);
};
export interface LoginViewProps
@@ -205,7 +235,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
const configuredLoginWidgets = useMemo<Record<string, unknown>>(() =>
(loginViewConfig?.['widgets'] as Record<string, unknown>) ?? {}, [ loginViewConfig ]);
const loginWidgetSlots = useMemo(() =>
{
return Object.entries(configuredLoginWidgets)
@@ -235,6 +265,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
const [ loginImagesVersion, setLoginImagesVersion ] = useState(0);
const loginUrl = GetConfigurationValue<string>('login.endpoint', '/api/auth/login');
const registerUrl = GetConfigurationValue<string>('login.register.endpoint', '/api/auth/register');
const roomTemplatesUrl = GetConfigurationValue<string>('login.room_templates.endpoint', '/api/auth/room-templates');
const forgotUrl = GetConfigurationValue<string>('login.forgot.endpoint', '/api/auth/forgot-password');
const newsUrl = interpolate(GetConfigurationValue<string>('login.news.url', ''));
const turnstileSiteKey = GetConfigurationValue<string>('login.turnstile.sitekey', '');
@@ -357,7 +388,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
return () => window.clearTimeout(timeout);
}, [ info ]);
const lockState = useMemo(() => readLock(), [ submitting ]);
const lockState = readLock();
const now = Date.now();
const isLocked = lockState.lockedUntil > now;
@@ -396,8 +427,12 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
});
let payload: Record<string, unknown> = {};
try { payload = await response.json(); }
catch { }
try
{
payload = await response.json();
}
catch
{ }
return { ok: response.ok, status: response.status, payload };
}, []);
@@ -445,45 +480,46 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
}
}, [ checkServerReachable ]);
const handleLoginSubmit = useCallback(async (event: FormEvent<HTMLFormElement>) =>
const loginAction = useCallback(async (_prev: null, formData: FormData): Promise<null> =>
{
event.preventDefault();
if(submitting || isEntering) return;
if(isEntering) return null;
const nowTs = Date.now();
if(nowTs - submitTimeRef.current < 1000) return;
if(nowTs - submitTimeRef.current < 1000) return null;
submitTimeRef.current = nowTs;
const state = readLock();
if(state.lockedUntil > nowTs)
const usernameInput = String(formData.get('username') || '').trim();
const passwordInput = String(formData.get('password') || '');
const rememberFlag = formData.get('remember') === 'on';
const lockSnapshot = readLock();
if(lockSnapshot.lockedUntil > nowTs)
{
const remaining = Math.ceil((state.lockedUntil - nowTs) / 1000);
const remaining = Math.ceil((lockSnapshot.lockedUntil - nowTs) / 1000);
setError(t('nitro.login.error.too_many_attempts', 'Too many attempts. Try again in %seconds%s.', [ 'seconds' ], [ String(remaining) ]));
return;
return null;
}
if(!username.trim() || !password)
if(!usernameInput || !passwordInput)
{
setError(t('nitro.login.error.missing_credentials', 'Please enter both your Habbo name and password.'));
return;
return null;
}
if(turnstileEnabled && !loginTurnstileToken)
{
setError(t('nitro.login.error.turnstile', 'Please complete the security check.'));
return;
return null;
}
setError(null);
setSubmitting(true);
try
{
const { ok, payload } = await postJson(loginUrl, {
username: username.trim(),
password,
remember: rememberMe,
username: usernameInput,
password: passwordInput,
remember: rememberFlag,
turnstileToken: turnstileEnabled ? loginTurnstileToken : undefined
});
@@ -493,10 +529,10 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
{
clearLock();
persistAccessTokenFromPayload(payload);
if(rememberMe) StoreRememberLoginFromPayload(payload, typeof payload.username === 'string' ? payload.username : username.trim(), ssoTicket);
if(rememberFlag) StoreRememberLoginFromPayload(payload, typeof payload.username === 'string' ? payload.username : usernameInput, ssoTicket);
else ClearRememberLogin();
onAuthenticated(ssoTicket);
return;
return null;
}
recordFailure();
@@ -504,17 +540,17 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
setError(message);
resetLoginTurnstile();
}
catch(err)
catch
{
recordFailure();
setError(t('nitro.login.error.login_unreachable', 'Unable to reach the login service. Please try again.'));
resetLoginTurnstile();
}
finally
{
setSubmitting(false);
}
}, [ submitting, isEntering, username, password, rememberMe, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile, pingLoginServer ]);
return null;
}, [ isEntering, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile ]);
const [ , submitLoginAction, isLoginPending ] = useActionState<null, FormData>(loginAction, null);
const checkEmailUrl = GetConfigurationValue<string>('login.check-email.endpoint', '/api/auth/check-email');
const checkUsernameUrl = GetConfigurationValue<string>('login.check-username.endpoint', '/api/auth/check-username');
@@ -566,7 +602,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
}
}, [ checkUsernameUrl, postJson ]);
const handleRegisterSubmit = useCallback(async (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; }, onDialogReset: () => void) =>
const handleRegisterSubmit = useCallback(async (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; templateId: number | null; }, onDialogReset: () => void) =>
{
if(turnstileEnabled && !body.turnstileToken)
{
@@ -586,7 +622,8 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
password: body.password,
figure: body.figure,
gender: body.gender,
turnstileToken: turnstileEnabled ? body.turnstileToken : undefined
turnstileToken: turnstileEnabled ? body.turnstileToken : undefined,
templateId: body.templateId ?? undefined
});
if(ok)
@@ -690,7 +727,10 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
<button
type="button"
className="login-widget-button"
onClick={ () => { if(btnLink) window.location.href = btnLink; } }
onClick={ () =>
{
if(btnLink) window.location.href = btnLink;
} }
>
{ btnText }
</button> }
@@ -736,7 +776,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
<div className="nitro-login-card">
<div className="card-title">{ t('nitro.login.card.title', 'What\'s your Habbo called?') }</div>
<form className="card-body" onSubmit={ handleLoginSubmit } autoComplete="on">
<form className="card-body" action={ submitLoginAction } autoComplete="on">
<div className="field">
<label htmlFor="login-username">{ t('login.username', 'Name of your Habbo') }</label>
<input
@@ -764,6 +804,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
<label className="remember-row">
<input
type="checkbox"
name="remember"
checked={ rememberMe }
onChange={ e => setRememberMe(e.target.checked) }
/>
@@ -789,11 +830,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
{ error && <div className="error-line">{ error }</div> }
{ info && <div className="info-line">{ info }</div> }
<div className="submit-row">
<button
type="submit"
className="ok-button"
disabled={ submitting || isEntering || isLocked }
>{ isEntering ? t('nitro.login.entering', 'Entering…') : loginPingingServer ? t('nitro.login.server.checking', 'Checking…') : t('login.title', 'Log in') }</button>
<LoginSubmitButton isEntering={ isEntering } isLocked={ isLocked } loginPingingServer={ loginPingingServer } />
</div>
<a className="forgot" onClick={ () => setMode('forgot') }>{ t('login.forgot_password', 'Forgotten your password?') }</a>
</form>
@@ -813,6 +850,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
info={ info }
turnstileEnabled={ turnstileEnabled }
turnstileSiteKey={ turnstileSiteKey }
roomTemplatesUrl={ roomTemplatesUrl }
/> }
{ mode === 'forgot' &&
@@ -841,13 +879,22 @@ interface DialogSharedProps
interface RegisterDialogProps extends DialogSharedProps
{
onSubmit: (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; }, onDialogReset: () => void) => void;
onSubmit: (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; templateId: number | null; }, onDialogReset: () => void) => Promise<void> | void;
onCheckEmail: (email: string) => Promise<{ available: boolean; error?: string }>;
onCheckUsername: (username: string) => Promise<{ available: boolean; error?: string }>;
onCheckServer: () => Promise<boolean>;
roomTemplatesUrl: string;
}
type RegisterStep = 'credentials' | 'avatar';
type RegisterStep = 'credentials' | 'avatar' | 'room';
interface RoomTemplate
{
templateId: number;
title: string;
description: string;
thumbnail: string;
}
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -945,7 +992,12 @@ const renderAvatarPreview = (figure: string, gender: GenderKey, setType: string)
if(resolved) return;
resolved = true;
if(timer !== null) window.clearTimeout(timer);
try { avatarImage?.dispose(); } catch {}
try
{
avatarImage?.dispose();
}
catch
{}
avatarImage = null;
if(url)
{
@@ -964,17 +1016,26 @@ const renderAvatarPreview = (figure: string, gender: GenderKey, setType: string)
const attempt = () =>
{
if(resolved) return;
if(attempts >= AVATAR_PREVIEW_MAX_ATTEMPTS) { finish(''); return; }
if(attempts >= AVATAR_PREVIEW_MAX_ATTEMPTS)
{
finish(''); return;
}
attempts++;
try { avatarImage?.dispose(); } catch {}
try
{
avatarImage?.dispose();
}
catch
{}
avatarImage = null;
try
{
avatarImage = GetAvatarRenderManager().createAvatarImage(figure, AvatarScaleType.LARGE, gender, {
resetFigure: () => attempt(),
dispose: () => {},
dispose: () =>
{},
disposed: false
});
}
@@ -984,7 +1045,10 @@ const renderAvatarPreview = (figure: string, gender: GenderKey, setType: string)
return;
}
if(!avatarImage) { finish(''); return; }
if(!avatarImage)
{
finish(''); return;
}
if(avatarImage.isPlaceholder()) return;
@@ -1024,7 +1088,10 @@ const useAvatarPreview = (figure: string, gender: GenderKey, setType: string): s
{
if(!cancelled) setUrl(result);
});
return () => { cancelled = true; };
return () =>
{
cancelled = true;
};
}, [ figure, gender, setType ]);
return url;
@@ -1049,7 +1116,10 @@ const AvatarPartRow: FC<AvatarPartRowProps> = ({ setType, selection, gender, onP
<div className="avatar-part-row">
<button type="button" className="arrow-btn" aria-label={ `Previous ${ setType }` } onClick={ onPrev }>&lsaquo;</button>
<div className={ `part-preview part-preview-${ setType }` }>
{ url && <img src={ url } alt={ `${ setType } preview` } onError={ e => { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> }
{ url && <img src={ url } alt={ `${ setType } preview` } onError={ e =>
{
(e.currentTarget).style.visibility = 'hidden';
} } /> }
</div>
<button type="button" className="arrow-btn" aria-label={ `Next ${ setType }` } onClick={ onNext }>&rsaquo;</button>
</div>
@@ -1058,7 +1128,7 @@ const AvatarPartRow: FC<AvatarPartRowProps> = ({ setType, selection, gender, onP
const RegisterDialog: FC<RegisterDialogProps> = props =>
{
const { onCancel, onSubmit, onCheckEmail, onCheckUsername, onCheckServer, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props;
const { onCancel, onSubmit, onCheckEmail, onCheckUsername, onCheckServer, submitting, error, info, turnstileEnabled, turnstileSiteKey, roomTemplatesUrl } = props;
const [ step, setStep ] = useState<RegisterStep>('credentials');
const [ email, setEmail ] = useState('');
@@ -1068,9 +1138,18 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
const [ gender, setGender ] = useState<GenderKey>('F');
const [ selection, setSelection ] = useState<FigureSelection>(() => ({ ...FALLBACK_DEFAULTS.F }));
const [ localError, setLocalError ] = useState<string | null>(null);
const [ checking, setChecking ] = useState(false);
const [ prevStep, setPrevStep ] = useState<RegisterStep>(step);
const [ turnstileToken, setTurnstileToken ] = useState('');
const [ resetSignal, setResetSignal ] = useState(0);
const [ roomTemplates, setRoomTemplates ] = useState<RoomTemplate[] | null>(null);
const [ roomTemplatesError, setRoomTemplatesError ] = useState<string | null>(null);
const [ selectedTemplateId, setSelectedTemplateId ] = useState<number | null>(null);
if(prevStep !== step)
{
setPrevStep(step);
setLocalError(null);
}
const [ serverReachable, setServerReachable ] = useState<boolean | null>(null);
const [ pingingServer, setPingingServer ] = useState(false);
@@ -1097,7 +1176,10 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
const ok = await onCheckServer();
if(!cancelled) setServerReachable(ok);
})();
return () => { cancelled = true; };
return () =>
{
cancelled = true;
};
}, [ onCheckServer ]);
const resetWidget = useCallback(() =>
@@ -1106,15 +1188,19 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
setResetSignal(prev => prev + 1);
}, []);
useEffect(() => { setLocalError(null); }, [ step ]);
const [ figureData, setFigureData ] = useState<FigureData | null>(null);
const figureDataUrlRaw = GetConfigurationValue<string>('avatar.figuredata.url', '');
const figureDataUrl = useMemo(() =>
{
if(!figureDataUrlRaw) return '';
try { return GetConfiguration().interpolate(figureDataUrlRaw); }
catch { return figureDataUrlRaw; }
try
{
return GetConfiguration().interpolate(figureDataUrlRaw);
}
catch
{
return figureDataUrlRaw;
}
}, [ figureDataUrlRaw ]);
useEffect(() =>
@@ -1123,9 +1209,16 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
let cancelled = false;
fetch(figureDataUrl, { credentials: 'omit' })
.then(r => r.ok ? r.json() : null)
.then(json => { if(!cancelled && json) setFigureData(json as FigureData); })
.catch(() => { });
return () => { cancelled = true; };
.then(json =>
{
if(!cancelled && json) setFigureData(json as FigureData);
})
.catch(() =>
{ });
return () =>
{
cancelled = true;
};
}, [ step, figureData, figureDataUrl ]);
const partOptions = useMemo(() =>
@@ -1151,7 +1244,10 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
{
if(!PART_ROWS.includes(st.type)) continue;
const palette = figureData.palettes.find(p => p.id === st.paletteId);
if(!palette) { result[st.type] = []; continue; }
if(!palette)
{
result[st.type] = []; continue;
}
result[st.type] = palette.colors
.filter(c => c.selectable && c.club === 0)
.map(c => ({ id: c.id, hex: '#' + c.hexCode.toUpperCase() }));
@@ -1188,12 +1284,16 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
const rawGender = typeof entry._gender === 'string' ? entry._gender.toUpperCase() : '';
const figure = typeof entry._figure === 'string' ? entry._figure : '';
if((rawGender !== 'M' && rawGender !== 'F') || !figure) continue;
parsed.push({ gender: rawGender as GenderKey, figure });
parsed.push({ gender: rawGender, figure });
}
if(parsed.length) setHotLooks(parsed);
})
.catch(() => { });
return () => { cancelled = true; };
.catch(() =>
{ });
return () =>
{
cancelled = true;
};
}, [ step, hotLooks.length ]);
const applyLook = useCallback((figure: string, lookGender: GenderKey) =>
@@ -1237,54 +1337,50 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
password.length >= 8 &&
password === confirm;
const handleCredentialsNext = async (event: FormEvent<HTMLFormElement>) =>
const credentialsAction = useCallback(async (_prev: null, _formData: FormData): Promise<null> =>
{
event.preventDefault();
setLocalError(null);
if(!email.trim() || !password || !confirm)
{
setLocalError(t('nitro.login.register.error.missing_fields', 'Please fill in every field.'));
return;
return null;
}
if(!EMAIL_REGEX.test(email.trim()))
{
setLocalError(t('nitro.login.register.error.invalid_email', 'Please enter a valid email address.'));
return;
return null;
}
if(password.length < 8)
{
setLocalError(t('nitro.login.register.error.password_too_short', 'Your password must be at least 8 characters.'));
return;
return null;
}
if(password !== confirm)
{
setLocalError(t('nitro.login.register.error.password_mismatch', 'Passwords do not match.'));
return;
return null;
}
setChecking(true);
try
const serverOk = await pingServer();
if(!serverOk)
{
const serverOk = await pingServer();
if(!serverOk)
{
setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.'));
return;
}
const result = await onCheckEmail(email.trim());
if(!result.available)
{
setLocalError(result.error || t('nitro.login.error.email_taken', 'This email is already in use.'));
return;
}
setStep('avatar');
setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.'));
return null;
}
finally
const result = await onCheckEmail(email.trim());
if(!result.available)
{
setChecking(false);
setLocalError(result.error || t('nitro.login.error.email_taken', 'This email is already in use.'));
return null;
}
};
setStep('avatar');
return null;
}, [ email, password, confirm, pingServer, onCheckEmail ]);
const [ , submitCredentialsAction, isCredentialsPending ] = useActionState<null, FormData>(credentialsAction, null);
const applyGender = (newGender: GenderKey) =>
{
@@ -1346,66 +1442,101 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
const figure = buildFigureString(selection);
const previewSrc = useAvatarPreview(figure, gender, AvatarSetType.FULL);
const handleAvatarSubmit = async (event: FormEvent<HTMLFormElement>) =>
const avatarAction = useCallback(async (_prev: null, _formData: FormData): Promise<null> =>
{
event.preventDefault();
setLocalError(null);
const trimmed = username.trim();
if(!trimmed)
{
setLocalError(t('nitro.login.register.error.username_required', 'Please choose a Habbo name.'));
return;
return null;
}
if(trimmed.length < 3 || trimmed.length > 16)
{
setLocalError(t('nitro.login.register.error.username_length', 'Habbo name must be 316 characters.'));
return;
return null;
}
if(turnstileEnabled && !turnstileToken)
{
setLocalError(t('nitro.login.error.turnstile', 'Please complete the security check.'));
return;
return null;
}
setChecking(true);
try
const serverOk = await pingServer();
if(!serverOk)
{
const serverOk = await pingServer();
if(!serverOk)
{
setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.'));
return;
}
const result = await onCheckUsername(trimmed);
if(!result.available)
{
setLocalError(result.error || t('nitro.login.error.username_taken', 'This Habbo name is already taken.'));
return;
}
}
finally
{
setChecking(false);
setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.'));
return null;
}
onSubmit({
username: trimmed,
const result = await onCheckUsername(trimmed);
if(!result.available)
{
setLocalError(result.error || t('nitro.login.error.username_taken', 'This Habbo name is already taken.'));
return null;
}
setStep('room');
return null;
}, [ username, turnstileEnabled, turnstileToken, pingServer, onCheckUsername ]);
const [ , submitAvatarAction, isAvatarPending ] = useActionState<null, FormData>(avatarAction, null);
const roomAction = useCallback(async (_prev: null, _formData: FormData): Promise<null> =>
{
setLocalError(null);
await onSubmit({
username: username.trim(),
email: email.trim(),
password,
figure,
figure: buildFigureString(selection),
gender,
turnstileToken
turnstileToken,
templateId: selectedTemplateId
}, resetWidget);
};
const busy = submitting || checking || pingingServer;
return null;
}, [ onSubmit, username, email, password, selection, gender, turnstileToken, selectedTemplateId, resetWidget ]);
const [ , submitRoomAction, isRoomPending ] = useActionState<null, FormData>(roomAction, null);
useEffect(() =>
{
if(step !== 'room' || roomTemplates !== null || !roomTemplatesUrl) return;
let cancelled = false;
setRoomTemplatesError(null);
fetch(roomTemplatesUrl, { credentials: 'include' })
.then(async r =>
{
if(!r.ok) throw new Error(`status ${ r.status }`);
return r.json();
})
.then(json =>
{
if(cancelled) return;
const list = Array.isArray((json as { templates?: unknown })?.templates)
? (json as { templates: RoomTemplate[] }).templates
: [];
setRoomTemplates(list);
})
.catch(() =>
{
if(cancelled) return;
setRoomTemplates([]);
setRoomTemplatesError(t('nitro.login.register.room.error', 'Could not load room options. You can still skip this step.'));
});
return () => { cancelled = true; };
}, [ step, roomTemplates, roomTemplatesUrl ]);
const busy = submitting || isCredentialsPending || isAvatarPending || isRoomPending || pingingServer;
const serverOffline = serverReachable === false;
return (
<div className="nitro-login-modal">
<div className={ `dialog ${ step === 'avatar' ? 'dialog-avatar' : '' }` }>
<div className={ `dialog ${ step === 'avatar' ? 'dialog-avatar' : '' } ${ step === 'room' ? 'dialog-room' : '' }` }>
<div className="nitro-login-card">
<div className="card-title">
<span>{ t('nitro.login.register.title', 'Habbo Details') }</span>
@@ -1413,7 +1544,7 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
</div>
{ step === 'credentials' &&
<form className="card-body" onSubmit={ handleCredentialsNext } autoComplete="on">
<form className="card-body" action={ submitCredentialsAction } autoComplete="on">
<div className="register-intro">
{ t('nitro.login.register.intro.credentials', 'Let\'s create your account. Enter your email and pick a password — we\'ll check that email isn\'t already in use.') }
</div>
@@ -1427,32 +1558,32 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
}
<div className="field">
<label htmlFor="register-email">{ t('nitro.login.register.email', 'Email') }</label>
<input id="register-email" type="email" maxLength={ 120 } autoComplete="email"
<input id="register-email" name="email" type="email" maxLength={ 120 } autoComplete="email"
value={ email } onChange={ e => setEmail(e.target.value) } />
</div>
<div className="field">
<label htmlFor="register-password">{ t('generic.password', 'Password') }</label>
<input id="register-password" type="password" maxLength={ 128 } autoComplete="new-password"
<input id="register-password" name="password" type="password" maxLength={ 128 } autoComplete="new-password"
value={ password } onChange={ e => setPassword(e.target.value) } />
</div>
<div className="field">
<label htmlFor="register-confirm">{ t('nitro.login.register.confirm_password', 'Confirm password') }</label>
<input id="register-confirm" type="password" maxLength={ 128 } autoComplete="new-password"
<input id="register-confirm" name="confirm" type="password" maxLength={ 128 } autoComplete="new-password"
value={ confirm } onChange={ e => setConfirm(e.target.value) } />
</div>
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
{ info && <div className="info-line">{ info }</div> }
<div className="step-footer">
<span className="step-indicator">1/2</span>
<span className="step-indicator">1/3</span>
<button type="submit" className="ok-button" disabled={ !credentialsValid || busy || serverOffline }>
{ checking || pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.register.next', 'Next') }
{ isCredentialsPending || pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.register.next', 'Next') }
</button>
</div>
</form>
}
{ step === 'avatar' &&
<form className="card-body" onSubmit={ handleAvatarSubmit } autoComplete="on">
<form className="card-body" action={ submitAvatarAction } autoComplete="on">
<div className="register-intro">
{ t('nitro.login.register.intro.avatar', 'Now it\'s time to make your own Habbo character! To make your own Habbo, please start by choosing your Habbo Name.') }
</div>
@@ -1495,11 +1626,15 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
</div>
<div className="avatar-preview">
{ previewSrc && <img src={ previewSrc } alt="Habbo preview" onError={ e => { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> }
{ previewSrc && <img src={ previewSrc } alt="Habbo preview" onError={ e =>
{
(e.currentTarget).style.visibility = 'hidden';
} } /> }
</div>
<div className="avatar-color-col">
{ PART_ROWS.map(setType => {
{ PART_ROWS.map(setType =>
{
const fallbackColor = FALLBACK_DEFAULTS[gender][setType]?.colors?.[0] ?? 0;
const currentColor = selection[setType]?.colors?.[0] ?? fallbackColor;
const swatchHex = hexFor(setType, currentColor);
@@ -1541,9 +1676,67 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
<div className="step-footer step-footer-split">
<button type="button" className="ok-button back-button" onClick={ () => setStep('credentials') } disabled={ busy }>{ t('nitro.login.register.back', 'Back') }</button>
<span className="step-indicator">2/2</span>
<span className="step-indicator">2/3</span>
<button type="submit" className="ok-button" disabled={ !username.trim() || busy || serverOffline }>
{ submitting ? t('nitro.login.register.creating', 'Creating…') : (checking || pingingServer) ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.register.next', 'Next') }
{ isAvatarPending ? t('nitro.login.server.checking', 'Checking…') : pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.register.next', 'Next') }
</button>
</div>
</form>
}
{ step === 'room' &&
<form className="card-body" action={ submitRoomAction } autoComplete="off">
<div className="register-intro">
{ t('nitro.login.register.intro.room', 'Last step — pick a starter room, or skip and create your own later.') }
</div>
{ serverOffline &&
<div className="error-line server-offline">
{ t('nitro.login.server.offline.long', 'The gameserver isn\'t running right now, so new accounts can\'t be created. Please try again in a moment.') }
<button type="button" className="retry-link" onClick={ pingServer } disabled={ pingingServer }>
{ pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.server.retry', 'Retry') }
</button>
</div>
}
<div className="room-templates-list">
<label className={ `room-template-option room-template-skip ${ selectedTemplateId === null ? 'selected' : '' }` }>
<input type="radio" name="register-room-template" checked={ selectedTemplateId === null }
onChange={ () => setSelectedTemplateId(null) } />
<div className="room-template-body">
<div className="room-template-title">{ t('nitro.login.register.room.skip.title', 'I\'m okay — I\'ll create my own rooms') }</div>
<div className="room-template-description">{ t('nitro.login.register.room.skip.description', 'Skip for now and start with an empty hotel inventory.') }</div>
</div>
</label>
{ roomTemplates === null && <div className="info-line">{ t('nitro.login.register.room.loading', 'Loading rooms…') }</div> }
{ roomTemplates !== null && roomTemplates.map(template => (
<label key={ template.templateId }
className={ `room-template-option ${ selectedTemplateId === template.templateId ? 'selected' : '' }` }>
<input type="radio" name="register-room-template" checked={ selectedTemplateId === template.templateId }
onChange={ () => setSelectedTemplateId(template.templateId) } />
{ template.thumbnail &&
<img className="room-template-thumb" src={ template.thumbnail } alt={ template.title }
onError={ e => { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> }
<div className="room-template-body">
<div className="room-template-title">{ template.title }</div>
{ template.description &&
<div className="room-template-description">{ template.description }</div> }
</div>
</label>
)) }
</div>
{ roomTemplatesError && <div className="error-line">{ roomTemplatesError }</div> }
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
{ info && <div className="info-line">{ info }</div> }
<div className="step-footer step-footer-split">
<button type="button" className="ok-button back-button" onClick={ () => setStep('avatar') } disabled={ busy }>{ t('nitro.login.register.back', 'Back') }</button>
<span className="step-indicator">3/3</span>
<button type="submit" className="ok-button" disabled={ busy || serverOffline }>
{ (submitting || isRoomPending) ? t('nitro.login.register.creating', 'Creating…') : t('nitro.login.register.finish', 'Finish') }
</button>
</div>
</form>
@@ -1557,12 +1750,19 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
interface ForgotDialogProps extends DialogSharedProps
{
onSubmit: (body: { email: string; turnstileToken: string; }, onDialogReset: () => void) => void;
onSubmit: (body: { email: string; turnstileToken: string; }, onDialogReset: () => void) => Promise<void> | void;
}
const ForgotSubmitButton: FC = () =>
{
const { pending } = useFormStatus();
return <button type="submit" className="ok-button" disabled={ pending }>{ t('nitro.login.forgot.send_email', 'Send email') }</button>;
};
const ForgotDialog: FC<ForgotDialogProps> = props =>
{
const { onCancel, onSubmit, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props;
const { onCancel, onSubmit, error, info, turnstileEnabled, turnstileSiteKey } = props;
const [ email, setEmail ] = useState('');
const [ localError, setLocalError ] = useState<string | null>(null);
const [ turnstileToken, setTurnstileToken ] = useState('');
@@ -1574,19 +1774,23 @@ const ForgotDialog: FC<ForgotDialogProps> = props =>
setResetSignal(prev => prev + 1);
}, []);
const handle = (event: FormEvent<HTMLFormElement>) =>
const forgotAction = useCallback(async (_prev: null, formData: FormData): Promise<null> =>
{
event.preventDefault();
setLocalError(null);
if(!email.trim())
const emailInput = String(formData.get('email') || '').trim();
if(!emailInput)
{
setLocalError(t('nitro.login.forgot.error.email_required', 'Please enter your email address.'));
return;
return null;
}
onSubmit({ email: email.trim(), turnstileToken }, resetWidget);
};
await onSubmit({ email: emailInput, turnstileToken }, resetWidget);
return null;
}, [ onSubmit, turnstileToken, resetWidget ]);
const [ , submitForgotAction ] = useActionState<null, FormData>(forgotAction, null);
return (
<div className="nitro-login-modal">
@@ -1596,10 +1800,10 @@ const ForgotDialog: FC<ForgotDialogProps> = props =>
<span>{ t('nitro.login.forgot.title', 'Reset password') }</span>
<span className="nitro-card-close-button" role="button" aria-label={ t('generic.close', 'Close') } onClick={ onCancel } />
</div>
<form className="card-body" onSubmit={ handle } autoComplete="on">
<form className="card-body" action={ submitForgotAction } autoComplete="on">
<div className="field">
<label htmlFor="forgot-email">{ t('nitro.login.forgot.email_label', 'Email address') }</label>
<input id="forgot-email" type="email" maxLength={ 120 } autoComplete="email"
<input id="forgot-email" name="email" type="email" maxLength={ 120 } autoComplete="email"
value={ email } onChange={ e => setEmail(e.target.value) } />
</div>
{ turnstileEnabled &&
@@ -1614,7 +1818,7 @@ const ForgotDialog: FC<ForgotDialogProps> = props =>
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
{ info && <div className="info-line">{ info }</div> }
<div className="submit-row">
<button type="submit" className="ok-button" disabled={ submitting }>{ t('nitro.login.forgot.send_email', 'Send email') }</button>
<ForgotSubmitButton />
</div>
</form>
</div>
+43 -61
View File
@@ -1,4 +1,4 @@
import { FC, useEffect, useRef } from 'react';
import { FC, useEffect, useEffectEvent, useRef, useState } from 'react';
declare global
{
@@ -13,41 +13,6 @@ declare global
}
}
const SCRIPT_ID = 'cf-turnstile-script';
const SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
let scriptPromise: Promise<void> | null = null;
const loadTurnstileScript = (): Promise<void> =>
{
if(typeof window === 'undefined') return Promise.resolve();
if(window.turnstile) return Promise.resolve();
if(scriptPromise) return scriptPromise;
scriptPromise = new Promise<void>((resolve, reject) =>
{
const existing = document.getElementById(SCRIPT_ID) as HTMLScriptElement | null;
if(existing)
{
existing.addEventListener('load', () => resolve());
existing.addEventListener('error', () => reject(new Error('Turnstile failed to load')));
return;
}
const script = document.createElement('script');
script.id = SCRIPT_ID;
script.src = SCRIPT_SRC;
script.async = true;
script.defer = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error('Turnstile failed to load'));
document.head.appendChild(script);
});
return scriptPromise;
};
export interface TurnstileWidgetProps
{
siteKey: string;
@@ -64,51 +29,68 @@ export const TurnstileWidget: FC<TurnstileWidgetProps> = props =>
const { siteKey, theme = 'light', size = 'normal', onToken, onExpire, onError, resetSignal = 0 } = props;
const containerRef = useRef<HTMLDivElement | null>(null);
const widgetIdRef = useRef<string | null>(null);
const [ scriptReady, setScriptReady ] = useState<boolean>(typeof window !== 'undefined' && !!window.turnstile);
const handleToken = useEffectEvent((token: string) => onToken(token));
const handleExpire = useEffectEvent(() => onExpire?.());
const handleError = useEffectEvent(() => onError?.());
useEffect(() =>
{
if(!siteKey || !containerRef.current) return;
if(scriptReady) return;
if(typeof window === 'undefined') return;
let cancelled = false;
loadTurnstileScript()
.then(() =>
const interval = window.setInterval(() =>
{
if(window.turnstile)
{
if(cancelled || !window.turnstile || !containerRef.current) return;
setScriptReady(true);
window.clearInterval(interval);
}
}, 100);
widgetIdRef.current = window.turnstile.render(containerRef.current, {
sitekey: siteKey,
theme,
size,
callback: (token: string) => onToken(token),
'expired-callback': () => onExpire?.(),
'error-callback': () => onError?.()
});
})
.catch(err =>
{
console.error('[Turnstile] script load failed', err);
onError?.();
});
return () => window.clearInterval(interval);
}, [ scriptReady ]);
useEffect(() =>
{
if(!scriptReady || !siteKey || !containerRef.current || !window.turnstile) return;
widgetIdRef.current = window.turnstile.render(containerRef.current, {
sitekey: siteKey,
theme,
size,
callback: handleToken,
'expired-callback': handleExpire,
'error-callback': handleError
});
return () =>
{
cancelled = true;
if(widgetIdRef.current && window.turnstile)
{
try { window.turnstile.remove(widgetIdRef.current); } catch { }
try
{
window.turnstile.remove(widgetIdRef.current);
}
catch
{ }
widgetIdRef.current = null;
}
};
}, [ siteKey, theme, size ]);
}, [ scriptReady, siteKey, theme, size ]);
useEffect(() =>
{
if(resetSignal <= 0) return;
if(widgetIdRef.current && window.turnstile)
{
try { window.turnstile.reset(widgetIdRef.current); } catch { }
try
{
window.turnstile.reset(widgetIdRef.current);
}
catch
{ }
}
}, [ resetSignal ]);
@@ -1,72 +0,0 @@
import { FC, FormEvent, useCallback, useState } from 'react';
import { TurnstileWidget } from '../TurnstileWidget';
import { t } from '../utils/i18n';
import { DialogSharedProps } from './shared';
export interface ForgotDialogProps extends DialogSharedProps
{
onSubmit: (body: { email: string; turnstileToken: string; }, onDialogReset: () => void) => void;
}
export const ForgotDialog: FC<ForgotDialogProps> = props =>
{
const { onCancel, onSubmit, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props;
const [ email, setEmail ] = useState('');
const [ localError, setLocalError ] = useState<string | null>(null);
const [ turnstileToken, setTurnstileToken ] = useState('');
const [ resetSignal, setResetSignal ] = useState(0);
const resetWidget = useCallback(() =>
{
setTurnstileToken('');
setResetSignal(prev => prev + 1);
}, []);
const handle = (event: FormEvent<HTMLFormElement>) =>
{
event.preventDefault();
setLocalError(null);
if(!email.trim())
{
setLocalError(t('nitro.login.error.missing_email', 'Please enter your email address.'));
return;
}
onSubmit({ email: email.trim(), turnstileToken }, resetWidget);
};
return (
<div className="nitro-login-modal">
<div className="dialog">
<div className="nitro-login-card">
<div className="card-title">
<span>{ t('nitro.login.forgot.title', 'Reset password') }</span>
<span className="nitro-card-close-button" role="button" aria-label={ t('generic.close', 'Close') } onClick={ onCancel } />
</div>
<form className="card-body" onSubmit={ handle } autoComplete="on">
<div className="field">
<label htmlFor="forgot-email">{ t('nitro.login.forgot.email.label', 'Email address') }</label>
<input id="forgot-email" type="email" maxLength={ 120 } autoComplete="email"
value={ email } onChange={ e => setEmail(e.target.value) } />
</div>
{ turnstileEnabled &&
<TurnstileWidget
siteKey={ turnstileSiteKey }
size="compact"
onToken={ setTurnstileToken }
onExpire={ () => setTurnstileToken('') }
onError={ () => setTurnstileToken('') }
resetSignal={ resetSignal }
/> }
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
{ info && <div className="info-line">{ info }</div> }
<div className="submit-row">
<button type="submit" className="ok-button" disabled={ submitting }>{ t('nitro.login.forgot.send', 'Send email') }</button>
</div>
</form>
</div>
</div>
</div>
);
};
+20 -5
View File
@@ -45,7 +45,10 @@ export const NewsWindow: FC<NewsWindowProps> = ({ newsUrl }) =>
useEffect(() =>
{
if(!newsUrl) { setFailed(true); return; }
if(!newsUrl)
{
setFailed(true); return;
}
let cancelled = false;
const controller = new AbortController();
@@ -63,7 +66,10 @@ export const NewsWindow: FC<NewsWindowProps> = ({ newsUrl }) =>
: Array.isArray(json) ? (json as RawNewsItem[]) : [];
setItems(rawList.map((raw, idx) => normalizeNewsItem(raw, idx + 1)));
})
.catch(() => { if(!cancelled) setFailed(true); });
.catch(() =>
{
if(!cancelled) setFailed(true);
});
return () =>
{
cancelled = true;
@@ -87,8 +93,14 @@ export const NewsWindow: FC<NewsWindowProps> = ({ newsUrl }) =>
const current = items[Math.min(index, items.length - 1)];
const hasMany = items.length > 1;
const bumpAuto = () => setAutoTick(t => t + 1);
const prev = () => { setIndex(i => (i - 1 + items.length) % items.length); bumpAuto(); };
const next = () => { setIndex(i => (i + 1) % items.length); bumpAuto(); };
const prev = () =>
{
setIndex(i => (i - 1 + items.length) % items.length); bumpAuto();
};
const next = () =>
{
setIndex(i => (i + 1) % items.length); bumpAuto();
};
const safeLinkUrl = resolveNewsLink(current.linkUrl);
const safeImageSrc = resolveNewsImage(current.image);
@@ -119,7 +131,10 @@ export const NewsWindow: FC<NewsWindowProps> = ({ newsUrl }) =>
<img
src={ safeImageSrc }
alt={ current.title || 'news' }
onError={ e => { (e.currentTarget as HTMLImageElement).style.display = 'none'; } }
onError={ e =>
{
(e.currentTarget).style.display = 'none';
} }
/>
</div>
}
@@ -1,633 +0,0 @@
import { FC, FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { GetConfiguration } from '@nitrots/nitro-renderer';
import { GetConfigurationValue } from '../../../api';
import { TurnstileWidget } from '../TurnstileWidget';
import { t } from '../utils/i18n';
import {
buildFigureString,
buildImagingUrl,
buildPartPreviewUrl,
EMAIL_REGEX,
FALLBACK_DEFAULTS,
FALLBACK_HEX,
FigureData,
FigureSelection,
GenderKey,
PART_ROWS
} from '../utils/figure';
import { DialogSharedProps } from './shared';
export interface RegisterDialogProps extends DialogSharedProps
{
onSubmit: (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; templateId: number | null; }, onDialogReset: () => void) => void;
onCheckEmail: (email: string) => Promise<{ available: boolean; error?: string }>;
onCheckUsername: (username: string) => Promise<{ available: boolean; error?: string }>;
onCheckServer: () => Promise<boolean>;
imagingUrl: string;
roomTemplatesUrl: string;
}
type RegisterStep = 'credentials' | 'avatar' | 'room';
interface RoomTemplate { templateId: number; title: string; description: string; thumbnail: string; }
export const RegisterDialog: FC<RegisterDialogProps> = props =>
{
const { onCancel, onSubmit, onCheckEmail, onCheckUsername, onCheckServer, imagingUrl, roomTemplatesUrl, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props;
const [ step, setStep ] = useState<RegisterStep>('credentials');
const [ email, setEmail ] = useState('');
const [ password, setPassword ] = useState('');
const [ confirm, setConfirm ] = useState('');
const [ username, setUsername ] = useState('');
const [ gender, setGender ] = useState<GenderKey>('F');
const [ selection, setSelection ] = useState<FigureSelection>(() => ({ ...FALLBACK_DEFAULTS.F }));
const [ localError, setLocalError ] = useState<string | null>(null);
const [ checking, setChecking ] = useState(false);
const [ turnstileToken, setTurnstileToken ] = useState('');
const [ resetSignal, setResetSignal ] = useState(0);
const [ serverReachable, setServerReachable ] = useState<boolean | null>(null);
const [ pingingServer, setPingingServer ] = useState(false);
const pingServer = useCallback(async () =>
{
setPingingServer(true);
try
{
const ok = await onCheckServer();
setServerReachable(ok);
return ok;
}
finally
{
setPingingServer(false);
}
}, [ onCheckServer ]);
useEffect(() =>
{
let cancelled = false;
(async () =>
{
const ok = await onCheckServer();
if(!cancelled) setServerReachable(ok);
})();
return () => { cancelled = true; };
}, [ onCheckServer ]);
const resetWidget = useCallback(() =>
{
setTurnstileToken('');
setResetSignal(prev => prev + 1);
}, []);
useEffect(() => { setLocalError(null); }, [ step ]);
const [ roomTemplates, setRoomTemplates ] = useState<RoomTemplate[] | null>(null);
const [ roomTemplatesError, setRoomTemplatesError ] = useState<string | null>(null);
const [ selectedTemplateId, setSelectedTemplateId ] = useState<number | null>(null);
const [ figureData, setFigureData ] = useState<FigureData | null>(null);
const figureDataUrlRaw = GetConfigurationValue<string>('avatar.figuredata.url', '');
const figureDataUrl = useMemo(() =>
{
if(!figureDataUrlRaw) return '';
try { return GetConfiguration().interpolate(figureDataUrlRaw); }
catch { return figureDataUrlRaw; }
}, [ figureDataUrlRaw ]);
useEffect(() =>
{
if(step !== 'avatar' || figureData || !figureDataUrl) return;
let cancelled = false;
fetch(figureDataUrl, { credentials: 'omit' })
.then(r => r.ok ? r.json() : null)
.then(json => { if(!cancelled && json) setFigureData(json as FigureData); })
.catch(() => { });
return () => { cancelled = true; };
}, [ step, figureData, figureDataUrl ]);
useEffect(() =>
{
if(step !== 'room' || roomTemplates !== null || !roomTemplatesUrl) return;
let cancelled = false;
setRoomTemplatesError(null);
fetch(roomTemplatesUrl, { credentials: 'include' })
.then(async r => {
if(!r.ok) throw new Error(`status ${ r.status }`);
return r.json();
})
.then(json => {
if(cancelled) return;
const list = Array.isArray((json as { templates?: unknown })?.templates)
? (json as { templates: RoomTemplate[] }).templates
: [];
setRoomTemplates(list);
})
.catch(() => {
if(cancelled) return;
setRoomTemplates([]);
setRoomTemplatesError(t('nitro.login.register.room.error', 'Could not load room options. You can still skip this step.'));
});
return () => { cancelled = true; };
}, [ step, roomTemplates, roomTemplatesUrl ]);
const partOptions = useMemo(() =>
{
const result: Record<string, Record<GenderKey, number[]>> = {};
if(!figureData) return result;
for(const st of figureData.setTypes)
{
if(!PART_ROWS.includes(st.type)) continue;
const forGender = (g: GenderKey) => st.sets
.filter(s => s.selectable && s.club === 0 && (s.gender === g || s.gender === 'U'))
.map(s => s.id);
result[st.type] = { M: forGender('M'), F: forGender('F') };
}
return result;
}, [ figureData ]);
const paletteOptions = useMemo(() =>
{
const result: Record<string, { id: number; hex: string }[]> = {};
if(!figureData) return result;
for(const st of figureData.setTypes)
{
if(!PART_ROWS.includes(st.type)) continue;
const palette = figureData.palettes.find(p => p.id === st.paletteId);
if(!palette) { result[st.type] = []; continue; }
result[st.type] = palette.colors
.filter(c => c.selectable && c.club === 0)
.map(c => ({ id: c.id, hex: '#' + c.hexCode.toUpperCase() }));
}
return result;
}, [ figureData ]);
const hexFor = useCallback((setType: string, colorId: number): string =>
{
const list = paletteOptions[setType];
if(list)
{
const found = list.find(c => c.id === colorId);
if(found) return found.hex;
}
return FALLBACK_HEX[colorId] || '#c9c9c9';
}, [ paletteOptions ]);
const [ hotLooks, setHotLooks ] = useState<{ gender: GenderKey; figure: string }[]>([]);
const [ hotLookIndex, setHotLookIndex ] = useState(-1);
useEffect(() =>
{
if(step !== 'avatar' || hotLooks.length) return;
let cancelled = false;
fetch('hotlooks.json', { credentials: 'omit' })
.then(r => r.ok ? r.json() : null)
.then((json: unknown) =>
{
if(cancelled || !Array.isArray(json)) return;
const parsed: { gender: GenderKey; figure: string }[] = [];
for(const entry of json as Record<string, unknown>[])
{
const rawGender = typeof entry._gender === 'string' ? entry._gender.toUpperCase() : '';
const figure = typeof entry._figure === 'string' ? entry._figure : '';
if((rawGender !== 'M' && rawGender !== 'F') || !figure) continue;
parsed.push({ gender: rawGender as GenderKey, figure });
}
if(parsed.length) setHotLooks(parsed);
})
.catch(() => { });
return () => { cancelled = true; };
}, [ step, hotLooks.length ]);
const applyLook = useCallback((figure: string, lookGender: GenderKey) =>
{
const next: FigureSelection = {};
for(const setPart of figure.split('.'))
{
const bits = setPart.split('-');
if(bits.length < 2) continue;
const setType = bits[0];
const partId = parseInt(bits[1], 10);
if(!setType || Number.isNaN(partId)) continue;
const colors: number[] = [];
for(let i = 2; i < bits.length; i++)
{
const c = parseInt(bits[i], 10);
if(!Number.isNaN(c)) colors.push(c);
}
next[setType] = { partId, colors };
}
for(const setType of PART_ROWS)
{
if(!next[setType]) next[setType] = { ...FALLBACK_DEFAULTS[lookGender][setType] };
}
setGender(lookGender);
setSelection(next);
}, []);
const cycleHotLook = useCallback(() =>
{
if(!hotLooks.length) return;
const nextIdx = (hotLookIndex + 1) % hotLooks.length;
setHotLookIndex(nextIdx);
const look = hotLooks[nextIdx];
applyLook(look.figure, look.gender);
}, [ hotLooks, hotLookIndex, applyLook ]);
const credentialsValid =
EMAIL_REGEX.test(email.trim()) &&
password.length >= 8 &&
password === confirm;
const handleCredentialsNext = async (event: FormEvent<HTMLFormElement>) =>
{
event.preventDefault();
setLocalError(null);
if(!email.trim() || !password || !confirm)
{
setLocalError(t('nitro.login.error.missing_fields', 'Please fill in every field.'));
return;
}
if(!EMAIL_REGEX.test(email.trim()))
{
setLocalError(t('nitro.login.error.invalid_email', 'Please enter a valid email address.'));
return;
}
if(password.length < 8)
{
setLocalError(t('nitro.login.error.password_too_short', 'Your password must be at least 8 characters.'));
return;
}
if(password !== confirm)
{
setLocalError(t('nitro.login.error.password_mismatch', 'Passwords do not match.'));
return;
}
setChecking(true);
try
{
const serverOk = await pingServer();
if(!serverOk)
{
setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.'));
return;
}
const result = await onCheckEmail(email.trim());
if(!result.available)
{
setLocalError(result.error || t('nitro.login.error.email_taken', 'This email is already in use.'));
return;
}
setStep('avatar');
}
finally
{
setChecking(false);
}
};
const applyGender = (newGender: GenderKey) =>
{
setGender(newGender);
setSelection({ ...FALLBACK_DEFAULTS[newGender] });
setHotLookIndex(-1);
};
const getPartList = useCallback((setType: string): number[] =>
{
const loaded = partOptions[setType]?.[gender];
if(loaded && loaded.length) return loaded;
const fallback = FALLBACK_DEFAULTS[gender][setType]?.partId;
return fallback !== undefined ? [ fallback ] : [];
}, [ partOptions, gender ]);
const getColorList = useCallback((setType: string): number[] =>
{
const loaded = paletteOptions[setType];
if(loaded && loaded.length) return loaded.map(c => c.id);
const fallback = FALLBACK_DEFAULTS[gender][setType]?.colors?.[0];
return fallback !== undefined ? [ fallback ] : [];
}, [ paletteOptions, gender ]);
const cyclePart = (setType: string, direction: 1 | -1) =>
{
const options = getPartList(setType);
if(!options.length) return;
const current = selection[setType]?.partId ?? options[0];
const idx = options.indexOf(current);
const nextIdx = ((idx === -1 ? 0 : idx) + direction + options.length) % options.length;
const colors = getColorList(setType);
setSelection(prev => ({
...prev,
[setType]: {
partId: options[nextIdx],
colors: prev[setType]?.colors ?? [ colors[0] ?? 0 ]
}
}));
};
const cycleColor = (setType: string, direction: 1 | -1) =>
{
const colors = getColorList(setType);
if(!colors.length) return;
const currentColor = selection[setType]?.colors?.[0] ?? colors[0];
const idx = colors.indexOf(currentColor);
const nextIdx = ((idx === -1 ? 0 : idx) + direction + colors.length) % colors.length;
const parts = getPartList(setType);
setSelection(prev => ({
...prev,
[setType]: {
partId: prev[setType]?.partId ?? parts[0],
colors: [ colors[nextIdx] ]
}
}));
};
const figure = buildFigureString(selection);
const previewSrc = buildImagingUrl(imagingUrl, figure, gender);
const handleAvatarSubmit = async (event: FormEvent<HTMLFormElement>) =>
{
event.preventDefault();
setLocalError(null);
const trimmed = username.trim();
if(!trimmed)
{
setLocalError(t('nitro.login.error.missing_username', 'Please choose a Habbo name.'));
return;
}
if(trimmed.length < 3 || trimmed.length > 16)
{
setLocalError(t('nitro.login.error.username_length', 'Habbo name must be 316 characters.'));
return;
}
if(turnstileEnabled && !turnstileToken)
{
setLocalError(t('nitro.login.error.turnstile', 'Please complete the security check.'));
return;
}
setChecking(true);
try
{
const serverOk = await pingServer();
if(!serverOk)
{
setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.'));
return;
}
const result = await onCheckUsername(trimmed);
if(!result.available)
{
setLocalError(result.error || t('nitro.login.error.username_taken', 'This Habbo name is already taken.'));
return;
}
}
finally
{
setChecking(false);
}
setStep('room');
};
const submitRegistration = (templateId: number | null) =>
{
onSubmit({
username: username.trim(),
email: email.trim(),
password,
figure,
gender,
turnstileToken,
templateId
}, resetWidget);
};
const handleRoomSubmit = (event: FormEvent<HTMLFormElement>) =>
{
event.preventDefault();
setLocalError(null);
submitRegistration(selectedTemplateId);
};
const busy = submitting || checking || pingingServer;
const serverOffline = serverReachable === false;
return (
<div className="nitro-login-modal">
<div className={ `dialog ${ step === 'avatar' ? 'dialog-avatar' : '' } ${ step === 'room' ? 'dialog-room' : '' }` }>
<div className="nitro-login-card">
<div className="card-title">
<span>{ t('nitro.login.register.title', 'Habbo Details') }</span>
<span className="nitro-card-close-button" role="button" aria-label={ t('generic.close', 'Close') } onClick={ onCancel } />
</div>
{ step === 'credentials' &&
<form className="card-body" onSubmit={ handleCredentialsNext } autoComplete="on">
<div className="register-intro">
{ t('nitro.login.register.intro.credentials', 'Let\'s create your account. Enter your email and pick a password — we\'ll check that email isn\'t already in use.') }
</div>
{ serverOffline &&
<div className="error-line server-offline">
{ t('nitro.login.server.offline.long', 'The gameserver isn\'t running right now, so new accounts can\'t be created. Please try again in a moment.') }
<button type="button" className="retry-link" onClick={ pingServer } disabled={ pingingServer }>
{ pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.server.retry', 'Retry') }
</button>
</div>
}
<div className="field">
<label htmlFor="register-email">{ t('register.email', 'Email') }</label>
<input id="register-email" type="email" maxLength={ 120 } autoComplete="email"
value={ email } onChange={ e => setEmail(e.target.value) } />
</div>
<div className="field">
<label htmlFor="register-password">{ t('generic.password', 'Password') }</label>
<input id="register-password" type="password" maxLength={ 128 } autoComplete="new-password"
value={ password } onChange={ e => setPassword(e.target.value) } />
</div>
<div className="field">
<label htmlFor="register-confirm">{ t('nitro.login.register.confirm.label', 'Confirm password') }</label>
<input id="register-confirm" type="password" maxLength={ 128 } autoComplete="new-password"
value={ confirm } onChange={ e => setConfirm(e.target.value) } />
</div>
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
{ info && <div className="info-line">{ info }</div> }
<div className="step-footer">
<span className="step-indicator">1/3</span>
<button type="submit" className="ok-button" disabled={ !credentialsValid || busy || serverOffline }>
{ checking || pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.register.next', 'Next') }
</button>
</div>
</form>
}
{ step === 'avatar' &&
<form className="card-body" onSubmit={ handleAvatarSubmit } autoComplete="on">
<div className="register-intro">
{ t('nitro.login.register.intro.avatar', 'Now it\'s time to make your own Habbo character! To make your own Habbo, please start by choosing your Habbo Name.') }
</div>
{ serverOffline &&
<div className="error-line server-offline">
{ t('nitro.login.server.offline.long', 'The gameserver isn\'t running right now, so new accounts can\'t be created. Please try again in a moment.') }
<button type="button" className="retry-link" onClick={ pingServer } disabled={ pingingServer }>
{ pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.server.retry', 'Retry') }
</button>
</div>
}
<div className="field">
<input id="register-username" type="text" maxLength={ 16 } autoComplete="username" placeholder={ t('nitro.login.register.username.placeholder', 'HabboName') }
value={ username } onChange={ e => setUsername(e.target.value) } />
</div>
<div className="gender-row">
<label>
<input type="radio" name="register-gender" checked={ gender === 'F' } onChange={ () => applyGender('F') } />
<span>{ t('avatareditor.generic.girl', 'Girl') }</span>
</label>
<label>
<input type="radio" name="register-gender" checked={ gender === 'M' } onChange={ () => applyGender('M') } />
<span>{ t('avatareditor.generic.boy', 'Boy') }</span>
</label>
</div>
<div className="avatar-builder">
<div className="avatar-part-col">
{ PART_ROWS.map(setType => {
const partPreviewSrc = buildPartPreviewUrl(imagingUrl, setType, selection, gender);
return (
<div className="avatar-part-row" key={ `part-${ setType }` }>
<button type="button" className="arrow-btn" aria-label={ `Previous ${ setType }` }
onClick={ () => cyclePart(setType, -1) }>&lsaquo;</button>
<div className={ `part-preview part-preview-${ setType }` }>
<img src={ partPreviewSrc } alt={ `${ setType } preview` } onError={ e => { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } />
</div>
<button type="button" className="arrow-btn" aria-label={ `Next ${ setType }` }
onClick={ () => cyclePart(setType, 1) }>&rsaquo;</button>
</div>
);
}) }
</div>
<div className="avatar-preview">
<img src={ previewSrc } alt="Habbo preview" onError={ e => { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } />
</div>
<div className="avatar-color-col">
{ PART_ROWS.map(setType => {
const fallbackColor = FALLBACK_DEFAULTS[gender][setType]?.colors?.[0] ?? 0;
const currentColor = selection[setType]?.colors?.[0] ?? fallbackColor;
const swatchHex = hexFor(setType, currentColor);
return (
<div className="avatar-color-row" key={ `color-${ setType }` }>
<button type="button" className="arrow-btn" aria-label={ `Previous color ${ setType }` }
onClick={ () => cycleColor(setType, -1) }>&lsaquo;</button>
<div className="color-swatch" style={ { background: swatchHex } } />
<button type="button" className="arrow-btn" aria-label={ `Next color ${ setType }` }
onClick={ () => cycleColor(setType, 1) }>&rsaquo;</button>
</div>
);
}) }
</div>
</div>
<div className="hot-looks-row">
<button type="button" className="ok-button hot-looks-button"
onClick={ cycleHotLook }
disabled={ !hotLooks.length || busy }
title={ hotLooks.length
? t('nitro.login.register.hotlooks.count', '%count% looks available', [ 'count' ], [ String(hotLooks.length) ])
: t('nitro.login.register.hotlooks.none', 'No hot looks loaded') }>
{ t('avatareditor.category.hotlooks', 'Hot Looks') }{ hotLookIndex >= 0 && hotLooks.length ? ` (${ hotLookIndex + 1 }/${ hotLooks.length })` : '' }
</button>
</div>
{ turnstileEnabled &&
<TurnstileWidget
siteKey={ turnstileSiteKey }
size="compact"
onToken={ setTurnstileToken }
onExpire={ () => setTurnstileToken('') }
onError={ () => setTurnstileToken('') }
resetSignal={ resetSignal }
/> }
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
{ info && <div className="info-line">{ info }</div> }
<div className="step-footer step-footer-split">
<button type="button" className="ok-button back-button" onClick={ () => setStep('credentials') } disabled={ busy }>{ t('generic.back', 'Back') }</button>
<span className="step-indicator">2/3</span>
<button type="submit" className="ok-button" disabled={ !username.trim() || busy || serverOffline }>
{ (checking || pingingServer) ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.register.next', 'Next') }
</button>
</div>
</form>
}
{ step === 'room' &&
<form className="card-body" onSubmit={ handleRoomSubmit } autoComplete="off">
<div className="register-intro">
{ t('nitro.login.register.intro.room', 'Last step — pick a starter room, or skip and create your own later.') }
</div>
{ serverOffline &&
<div className="error-line server-offline">
{ t('nitro.login.server.offline.long', 'The gameserver isn\'t running right now, so new accounts can\'t be created. Please try again in a moment.') }
<button type="button" className="retry-link" onClick={ pingServer } disabled={ pingingServer }>
{ pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.server.retry', 'Retry') }
</button>
</div>
}
<div className="room-templates-list">
<label className={ `room-template-option room-template-skip ${ selectedTemplateId === null ? 'selected' : '' }` }>
<input type="radio" name="register-room-template" checked={ selectedTemplateId === null }
onChange={ () => setSelectedTemplateId(null) } />
<div className="room-template-body">
<div className="room-template-title">{ t('nitro.login.register.room.skip.title', 'I\'m okay — I\'ll create my own rooms') }</div>
<div className="room-template-description">{ t('nitro.login.register.room.skip.description', 'Skip for now and start with an empty hotel inventory.') }</div>
</div>
</label>
{ roomTemplates === null && <div className="info-line">{ t('nitro.login.register.room.loading', 'Loading rooms…') }</div> }
{ roomTemplates !== null && roomTemplates.map(template => (
<label key={ template.templateId }
className={ `room-template-option ${ selectedTemplateId === template.templateId ? 'selected' : '' }` }>
<input type="radio" name="register-room-template" checked={ selectedTemplateId === template.templateId }
onChange={ () => setSelectedTemplateId(template.templateId) } />
{ template.thumbnail &&
<img className="room-template-thumb" src={ template.thumbnail } alt={ template.title }
onError={ e => { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> }
<div className="room-template-body">
<div className="room-template-title">{ template.title }</div>
{ template.description &&
<div className="room-template-description">{ template.description }</div> }
</div>
</label>
)) }
</div>
{ roomTemplatesError && <div className="error-line">{ roomTemplatesError }</div> }
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
{ info && <div className="info-line">{ info }</div> }
<div className="step-footer step-footer-split">
<button type="button" className="ok-button back-button" onClick={ () => setStep('avatar') } disabled={ busy }>{ t('generic.back', 'Back') }</button>
<span className="step-indicator">3/3</span>
<button type="submit" className="ok-button" disabled={ busy || serverOffline }>
{ submitting ? t('nitro.login.register.creating', 'Creating…') : t('nitro.login.register.finish', 'Finish') }
</button>
</div>
</form>
}
</div>
</div>
</div>
);
};
@@ -1,9 +0,0 @@
export interface DialogSharedProps
{
onCancel: () => void;
submitting: boolean;
error: string | null;
info: string | null;
turnstileEnabled: boolean;
turnstileSiteKey: string;
}
+10 -3
View File
@@ -8,7 +8,8 @@ export const t = (key: string, fallback: string, params?: string[], replacements
const value = LocalizeText(key, params ?? null, replacements ?? null);
if(value && value !== key) return value;
}
catch {}
catch
{}
if(!params || !replacements) return fallback;
let out = fallback;
@@ -22,6 +23,12 @@ export const t = (key: string, fallback: string, params?: string[], replacements
export const interpolate = (value: string | null | undefined): string =>
{
if(!value) return '';
try { return GetConfiguration().interpolate(value); }
catch { return value; }
try
{
return GetConfiguration().interpolate(value);
}
catch
{
return value;
}
};
+10 -3
View File
@@ -13,11 +13,18 @@ export const readLock = (): AttemptState =>
if(!raw) return { attempts: 0, firstAt: 0, lockedUntil: 0 };
return JSON.parse(raw);
}
catch { return { attempts: 0, firstAt: 0, lockedUntil: 0 }; }
catch
{
return { attempts: 0, firstAt: 0, lockedUntil: 0 };
}
};
export const writeLock = (state: AttemptState) =>
{
try { sessionStorage.setItem(LOCK_KEY, JSON.stringify(state)); }
catch { }
try
{
sessionStorage.setItem(LOCK_KEY, JSON.stringify(state));
}
catch
{ }
};
+12 -3
View File
@@ -12,8 +12,14 @@ export const resolveNewsImage = (raw: string | null | undefined): string =>
if(value.startsWith('//')) return window.location.protocol + value;
if(value.startsWith('/'))
{
try { return new URL(value, window.location.origin).href; }
catch { return window.location.origin + value; }
try
{
return new URL(value, window.location.origin).href;
}
catch
{
return window.location.origin + value;
}
}
if(value.startsWith('data:'))
{
@@ -46,5 +52,8 @@ export const resolveNewsLink = (raw: string | null | undefined): string =>
if(proto !== 'http:' && proto !== 'https:') return '';
return url.href;
}
catch { return ''; }
catch
{
return '';
}
};
+89 -22
View File
@@ -1,8 +1,9 @@
import { AddLinkEventTracker, CreateLinkEvent, ILinkEventTracker, RemoveLinkEventTracker, RoomEngineEvent, RoomId, RoomObjectCategory, RoomObjectType } from '@nitrots/nitro-renderer';
import { FC, useEffect, useRef, useState } from 'react';
import { GetRoomSession, ISelectedUser } from '../../api';
import { FC, useEffect, useMemo, useRef, useState } from 'react';
import { FaTimes, FaUserSlash } from 'react-icons/fa';
import { GetRoomSession, ISelectedUser, LocalizeText } from '../../api';
import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common';
import { useModTools, useNitroEvent, useObjectSelectedEvent } from '../../hooks';
import { useModTools, useNitroEvent, useObjectSelectedEvent, useRoomUserListSnapshot } from '../../hooks';
import { ModToolsChatlogView } from './views/room/ModToolsChatlogView';
import { ModToolsRoomView } from './views/room/ModToolsRoomView';
import { ModToolsTicketsView } from './views/tickets/ModToolsTicketsView';
@@ -15,8 +16,24 @@ export const ModToolsView: FC<{}> = props =>
const [ currentRoomId, setCurrentRoomId ] = useState<number>(-1);
const [ selectedUser, setSelectedUser ] = useState<ISelectedUser>(null);
const [ isTicketsVisible, setIsTicketsVisible ] = useState(false);
const { openRooms = [], openRoomChatlogs = [], openUserChatlogs = [], openUserInfos = [], openRoomInfo = null, closeRoomInfo = null, toggleRoomInfo = null, openRoomChatlog = null, closeRoomChatlog = null, toggleRoomChatlog = null, openUserInfo = null, closeUserInfo = null, toggleUserInfo = null, openUserChatlog = null, closeUserChatlog = null, toggleUserChatlog = null } = useModTools();
const { tickets = [], openRooms = [], openRoomChatlogs = [], openUserChatlogs = [], openUserInfos = [], openRoomInfo = null, closeRoomInfo = null, toggleRoomInfo = null, openRoomChatlog = null, closeRoomChatlog = null, toggleRoomChatlog = null, openUserInfo = null, closeUserInfo = null, toggleUserInfo = null, openUserChatlog = null, closeUserChatlog = null, toggleUserChatlog = null } = useModTools();
const elementRef = useRef<HTMLDivElement>(null);
// Reactive room roster — used to auto-clear the selected user if
// they leave the room while the panel is open, and to show an
// online dot on the selected-user button without going through
// userDataManager imperatively on every render.
const roomUserList = useRoomUserListSnapshot();
// Count of OPEN tickets the moderator hasn't picked yet — shown
// as a badge on the Report Tool button so a new ticket is visible
// immediately, without forcing the user to click through.
const openTicketsCount = useMemo(
() => tickets.filter(ticket => ticket && (ticket.state === 1)).length,
[ tickets ]
);
const isSelectedUserPresent = useMemo(
() => !!(selectedUser && roomUserList.some(user => user && (user.webID === selectedUser.userId))),
[ selectedUser, roomUserList ]
);
useNitroEvent<RoomEngineEvent>([
RoomEngineEvent.INITIALIZED,
@@ -117,32 +134,82 @@ export const ModToolsView: FC<{}> = props =>
return () => RemoveLinkEventTracker(linkTracker);
}, [ openRoomInfo, closeRoomInfo, toggleRoomInfo, openRoomChatlog, closeRoomChatlog, toggleRoomChatlog, openUserInfo, closeUserInfo, toggleUserInfo, openUserChatlog, closeUserChatlog, toggleUserChatlog ]);
const isRoomInfoOpen = currentRoomId > 0 && openRooms.includes(currentRoomId);
const isRoomChatlogOpen = currentRoomId > 0 && openRoomChatlogs.includes(currentRoomId);
const isInRoom = currentRoomId > 0;
const isRoomInfoOpen = isInRoom && openRooms.includes(currentRoomId);
const isRoomChatlogOpen = isInRoom && openRoomChatlogs.includes(currentRoomId);
const isUserInfoOpen = selectedUser && openUserInfos.includes(selectedUser.userId);
const noRoomHint = LocalizeText('modtools.window.no.room');
return (
<>
{ isVisible &&
<NitroCardView className="nitro-mod-tools min-w-[200px]" theme="primary-slim" uniqueKey="mod-tools" windowPosition={ DraggableWindowPosition.TOP_LEFT } >
<NitroCardHeaderView headerText={ 'Mod Tools' } onCloseClick={ event => setIsVisible(false) } />
<NitroCardView className="nitro-mod-tools min-w-[240px] max-w-[260px]" theme="primary-slim" uniqueKey="mod-tools" windowPosition={ DraggableWindowPosition.TOP_LEFT } >
<NitroCardHeaderView headerText={ LocalizeText('modtools.window.title') } onCloseClick={ event => setIsVisible(false) } />
<NitroCardContentView className="text-black" gap={ 2 }>
<Button active={ isRoomInfoOpen } disabled={ (currentRoomId <= 0) } gap={ 2 } justifyContent="start" onClick={ event => CreateLinkEvent(`mod-tools/toggle-room-info/${ currentRoomId }`) }>
<div className="nitro-icon icon-small-room shrink-0" /> Room Tool
</Button>
<Button active={ isRoomChatlogOpen } disabled={ (currentRoomId <= 0) } gap={ 2 } innerRef={ elementRef } justifyContent="start" onClick={ event => CreateLinkEvent(`mod-tools/toggle-room-chatlog/${ currentRoomId }`) }>
<div className="nitro-icon icon-chat-history shrink-0" /> Chatlog Tool
</Button>
<Button active={ !!isUserInfoOpen } disabled={ !selectedUser } gap={ 2 } justifyContent="start" onClick={ () => CreateLinkEvent(`mod-tools/toggle-user-info/${ selectedUser.userId }`) }>
<div className="nitro-icon icon-user shrink-0" />
{/* Room tools */}
<div className="flex flex-col gap-1.5">
<div className="text-[.6rem] uppercase tracking-wide opacity-60 font-semibold pl-1">{ LocalizeText('modtools.window.section.room') }</div>
<Button active={ isRoomInfoOpen } disabled={ !isInRoom } gap={ 2 } justifyContent="start" title={ !isInRoom ? noRoomHint : undefined } onClick={ () => CreateLinkEvent(`mod-tools/toggle-room-info/${ currentRoomId }`) }>
<div className="nitro-icon icon-small-room shrink-0" />
<span className="grow text-start">{ LocalizeText('modtools.window.tools.room') }</span>
</Button>
<Button active={ isRoomChatlogOpen } disabled={ !isInRoom } gap={ 2 } innerRef={ elementRef } justifyContent="start" title={ !isInRoom ? noRoomHint : undefined } onClick={ () => CreateLinkEvent(`mod-tools/toggle-room-chatlog/${ currentRoomId }`) }>
<div className="nitro-icon icon-chat-history shrink-0" />
<span className="grow text-start">{ LocalizeText('modtools.window.tools.chatlog') }</span>
</Button>
</div>
{/* Selected user */}
<div className="flex flex-col gap-1.5">
<div className="text-[.6rem] uppercase tracking-wide opacity-60 font-semibold pl-1">{ LocalizeText('modtools.window.section.user') }</div>
{ selectedUser
? <span className="truncate">{ selectedUser.username }</span>
: <span className="opacity-50 italic">Select a user</span>
? (
<div className={ `flex flex-col gap-1.5 rounded p-1.5 border ${ isSelectedUserPresent ? 'bg-gradient-to-r from-emerald-50 to-transparent border-emerald-100' : 'bg-gradient-to-r from-zinc-50 to-transparent border-zinc-200' }` }>
<div className="flex items-center gap-1.5">
<span className={ `inline-block w-2 h-2 rounded-full shrink-0 ${ isSelectedUserPresent ? 'bg-emerald-500' : 'bg-zinc-400' }` }
title={ isSelectedUserPresent ? LocalizeText('modtools.window.user.in_room') : LocalizeText('modtools.window.user.left_room') }
aria-label={ isSelectedUserPresent ? LocalizeText('modtools.userinfo.presence.in_room') : LocalizeText('modtools.window.user.left_room') } />
<span className="truncate grow text-start text-sm font-semibold leading-tight">{ selectedUser.username }</span>
<button
className="inline-flex items-center justify-center w-5 h-5 rounded text-zinc-500 hover:text-rose-600 hover:bg-rose-100 shrink-0 transition-colors"
onClick={ event =>
{
event.stopPropagation();
setSelectedUser(null);
} }
title={ LocalizeText('modtools.window.user.clear') }>
<FaTimes size={ 10 } />
</button>
</div>
<Button active={ !!isUserInfoOpen } gap={ 2 } justifyContent="start" onClick={ () => CreateLinkEvent(`mod-tools/toggle-user-info/${ selectedUser.userId }`) }>
<div className="nitro-icon icon-user shrink-0" />
<span className="grow text-start">{ LocalizeText('modtools.window.user.open_info') }</span>
</Button>
</div>
)
: (
<div className="flex items-center gap-2 rounded p-2 border border-dashed border-zinc-300 bg-zinc-50/50 opacity-70">
<FaUserSlash className="text-zinc-400 shrink-0" size={ 14 } />
<span className="text-xs italic">{ LocalizeText('modtools.window.select.user') }</span>
</div>
)
}
</Button>
<Button active={ isTicketsVisible } gap={ 2 } justifyContent="start" onClick={ () => setIsTicketsVisible(prevValue => !prevValue) }>
<div className="nitro-icon icon-tickets shrink-0" /> Report Tool
</Button>
</div>
{/* Reports */}
<div className="flex flex-col gap-1.5">
<div className="text-[.6rem] uppercase tracking-wide opacity-60 font-semibold pl-1">{ LocalizeText('modtools.window.section.reports') }</div>
<Button active={ isTicketsVisible } gap={ 2 } justifyContent="start" onClick={ () => setIsTicketsVisible(prevValue => !prevValue) }>
<div className="nitro-icon icon-tickets shrink-0" />
<span className="grow text-start">{ LocalizeText('modtools.window.tools.report') }</span>
{ (openTicketsCount > 0) &&
<span
className="inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1.5 rounded-full bg-rose-500 text-white text-xs font-semibold shrink-0 [box-shadow:0_0_0_2px_rgba(244,63,94,.25)]"
title={ LocalizeText(openTicketsCount === 1 ? 'modtools.window.tickets.open' : 'modtools.window.tickets.open.many', [ 'count' ], [ openTicketsCount.toString() ]) }>
{ openTicketsCount > 99 ? '99+' : openTicketsCount }
</span> }
</Button>
</div>
</NitroCardContentView>
</NitroCardView> }
{ (openRooms.length > 0) && openRooms.map(roomId => <ModToolsRoomView key={ roomId } roomId={ roomId } onCloseClick={ () => CreateLinkEvent(`mod-tools/close-room-info/${ roomId }`) } />) }
@@ -1,7 +1,8 @@
import { ChatRecordData, CreateLinkEvent } from '@nitrots/nitro-renderer';
import { FC, useMemo } from 'react';
import { TryVisitRoom } from '../../../../api';
import { Button, Column, Flex, Grid, InfiniteScroll, Text } from '../../../../common';
import { FaCommentDots, FaDoorOpen, FaSignInAlt, FaTools } from 'react-icons/fa';
import { LocalizeText, TryVisitRoom } from '../../../../api';
import { Column, InfiniteScroll } from '../../../../common';
import { useModTools } from '../../../../hooks';
import { ChatlogRecord } from './ChatlogRecord';
@@ -43,46 +44,61 @@ export const ChatlogView: FC<ChatlogViewProps> = props =>
return results;
}, [ records ]);
const RoomInfo = (props: { roomId: number, roomName: string }) =>
{
return (
<Flex alignItems="center" className="bg-muted rounded p-2" gap={ 2 } justifyContent="between">
<Text bold truncate>{ props.roomName }</Text>
<div className="flex gap-1 shrink-0">
<Button size="sm" onClick={ event => TryVisitRoom(props.roomId) }>Visit</Button>
<Button size="sm" onClick={ event => openRoomInfo(props.roomId) }>Room Tools</Button>
</div>
</Flex>
);
};
const totalMessages = useMemo(
() => allRecords.filter(r => !r.isRoomInfo).length,
[ allRecords ]
);
const RoomInfo = (props: { roomId: number, roomName: string }) => (
<div className="flex items-center gap-2 bg-gradient-to-r from-sky-50 to-transparent rounded p-2 border border-sky-100 my-1">
<FaDoorOpen className="text-sky-600 shrink-0" size={ 14 } />
<div className="font-semibold leading-tight grow truncate">{ props.roomName }</div>
<div className="flex gap-1 shrink-0">
<button
className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs bg-white border border-sky-200 text-sky-700 hover:bg-sky-100 transition-colors"
onClick={ () => TryVisitRoom(props.roomId) }>
<FaSignInAlt size={ 10 } /> { LocalizeText('modtools.chatlog.visit') }
</button>
<button
className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs bg-white border border-sky-200 text-sky-700 hover:bg-sky-100 transition-colors"
onClick={ () => openRoomInfo(props.roomId) }>
<FaTools size={ 10 } /> { LocalizeText('modtools.chatlog.tools') }
</button>
</div>
</div>
);
const isEmpty = !records || records.length === 0 || totalMessages === 0;
return (
<>
<Column fit gap={ 0 } overflow="hidden">
<Column gap={ 2 }>
<Grid className="text-black font-bold border-bottom pb-1 text-[11px] uppercase opacity-60 tracking-wider" gap={ 1 }>
<div className="col-span-2">Time</div>
<div className="col-span-3">User</div>
<div className="col-span-7">Message</div>
</Grid>
</Column>
{ (records && (records.length > 0)) &&
<InfiniteScroll rowRender={ (row: ChatlogRecord) =>
{
return (
<>
{ row.isRoomInfo &&
<RoomInfo roomId={ row.roomId } roomName={ row.roomName } /> }
{ !row.isRoomInfo &&
<Grid alignItems="center" className="log-entry py-1.5 border-bottom even:bg-black/[0.03]" fullHeight={ false } gap={ 1 }>
<Text className="col-span-2 opacity-60 text-[11px]">{ row.timestamp }</Text>
<Text bold pointer underline className="col-span-3" onClick={ event => CreateLinkEvent(`mod-tools/open-user-info/${ row.habboId }`) }>{ row.username }</Text>
<Text textBreak wrap className="col-span-7">{ row.message }</Text>
</Grid> }
</>
);
} } rows={ allRecords } /> }
</Column>
</>
<Column fit gap={ 0 } overflow="hidden">
{/* Column headers */}
<div className="grid grid-cols-[60px_120px_1fr] gap-2 text-[.7rem] uppercase tracking-wide opacity-60 font-semibold border-b border-zinc-200 pb-1 px-1">
<div>{ LocalizeText('modtools.chatlog.column.time') }</div>
<div>{ LocalizeText('modtools.chatlog.column.user') }</div>
<div>{ LocalizeText('modtools.chatlog.column.message') }</div>
</div>
{ isEmpty
? <div className="flex flex-col items-center justify-center gap-1 py-6 opacity-50 text-sm">
<FaCommentDots size={ 22 } />
<span>{ LocalizeText('modtools.chatlog.empty') }</span>
</div>
: <InfiniteScroll rowRender={ (row: ChatlogRecord) =>
{
if(row.isRoomInfo) return <RoomInfo roomId={ row.roomId } roomName={ row.roomName } />;
return (
<div className={ `grid grid-cols-[60px_120px_1fr] gap-2 items-start px-1 py-1.5 text-sm border-b border-zinc-100 even:bg-black/[0.02] hover:bg-sky-50/50 transition-colors ${ row.hasHighlighting ? 'bg-amber-50/60' : '' }` }>
<span className="font-mono text-[.7rem] opacity-70 tabular-nums whitespace-nowrap">{ row.timestamp }</span>
<button
className="text-left font-semibold text-sky-700 hover:text-sky-900 hover:underline truncate"
onClick={ () => CreateLinkEvent(`mod-tools/open-user-info/${ row.habboId }`) }>
{ row.username }
</button>
<span className="break-words">{ row.message }</span>
</div>
);
} } rows={ allRecords } /> }
</Column>
);
};
@@ -1,6 +1,7 @@
import { ChatRecordData, GetRoomChatlogMessageComposer, RoomChatlogEvent } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { SendMessageComposer } from '../../../../api';
import { FaSpinner } from 'react-icons/fa';
import { LocalizeText, SendMessageComposer } from '../../../../api';
import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { useMessageEvent } from '../../../../hooks';
import { ChatlogView } from '../chatlog/ChatlogView';
@@ -30,14 +31,16 @@ export const ModToolsChatlogView: FC<ModToolsChatlogViewProps> = props =>
SendMessageComposer(new GetRoomChatlogMessageComposer(roomId));
}, [ roomId ]);
if(!roomChatlog) return null;
return (
<NitroCardView className="nitro-mod-tools-chatlog min-w-[400px] max-h-[500px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardHeaderView headerText={ `Room Chatlog` } onCloseClick={ onCloseClick } />
<NitroCardContentView className="text-black" overflow="auto">
{ roomChatlog &&
<ChatlogView records={ [ roomChatlog ] } /> }
<NitroCardView className="nitro-mod-tools-chatlog min-w-[460px] max-w-[520px] max-h-[500px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardHeaderView headerText={ LocalizeText('modtools.room.chatlog.title') } onCloseClick={ onCloseClick } />
<NitroCardContentView className="text-black" gap={ 1 } overflow="auto">
{ roomChatlog
? <ChatlogView records={ [ roomChatlog ] } />
: <div className="flex flex-col items-center justify-center gap-2 py-8 opacity-50 text-sm">
<FaSpinner className="animate-spin" size={ 22 } />
<span>{ LocalizeText('modtools.user.chatlog.loading') }</span>
</div> }
</NitroCardContentView>
</NitroCardView>
);
@@ -1,7 +1,8 @@
import { CreateLinkEvent, GetModeratorRoomInfoMessageComposer, ModerateRoomMessageComposer, ModeratorActionMessageComposer, ModeratorRoomInfoEvent } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { SendMessageComposer, TryVisitRoom } from '../../../../api';
import { Button, Column, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
import { FaBullhorn, FaCommentDots, FaDoorOpen, FaExclamationTriangle, FaSignInAlt, FaSync, FaUserShield, FaUsers } from 'react-icons/fa';
import { LocalizeText, SendMessageComposer, TryVisitRoom } from '../../../../api';
import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
import { useMessageEvent } from '../../../../hooks';
interface ModToolsRoomViewProps
@@ -25,7 +26,9 @@ export const ModToolsRoomView: FC<ModToolsRoomViewProps> = props =>
const [ changeRoomName, setChangeRoomName ] = useState(false);
const [ message, setMessage ] = useState('');
const handleClick = (action: string, value?: string) =>
const refresh = () => SendMessageComposer(new GetModeratorRoomInfoMessageComposer(roomId));
const handleClick = (action: string) =>
{
if(!action) return;
@@ -66,55 +69,102 @@ export const ModToolsRoomView: FC<ModToolsRoomViewProps> = props =>
SendMessageComposer(new GetModeratorRoomInfoMessageComposer(roomId));
setInfoRequested(true);
}, [ roomId, infoRequested, setInfoRequested ]);
}, [ roomId, infoRequested ]);
const isLoaded = loadedRoomId !== null;
const hasMessage = message.trim().length > 0;
const ownerPillClass = ownerInRoom
? 'bg-emerald-100 text-emerald-700 border-emerald-200'
: 'bg-zinc-100 text-zinc-600 border-zinc-200';
const ownerDotClass = ownerInRoom ? 'bg-emerald-500' : 'bg-zinc-400';
return (
<NitroCardView className="nitro-mod-tools-room min-w-[280px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardHeaderView headerText={ 'Room Info' } onCloseClick={ event => onCloseClick() } />
<NitroCardView className="nitro-mod-tools-room min-w-[400px] max-w-[460px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardHeaderView headerText={ LocalizeText('modtools.roominfo.title') } onCloseClick={ () => onCloseClick() } />
<NitroCardContentView className="text-black" gap={ 2 }>
{ name &&
<div className="bg-muted rounded px-2 py-1.5 text-center">
<Text bold truncate>{ name }</Text>
{/* Identity header */}
<div className="flex items-center gap-2 bg-gradient-to-r from-sky-50 to-transparent rounded p-2 border border-sky-100">
<FaDoorOpen className="text-sky-600 shrink-0" size={ 16 } />
<div className="flex flex-col grow min-w-0">
<Text bold className="truncate text-base leading-tight">{ name || LocalizeText('modtools.roominfo.loading') }</Text>
<Text className="opacity-60 text-xs truncate">#{ roomId }</Text>
</div>
}
<div className="flex gap-2">
<Column grow gap={ 1 }>
<div className="flex items-center gap-1">
<Text bold className="opacity-60 shrink-0">Owner:</Text>
<Text bold pointer truncate underline onClick={ () => CreateLinkEvent(`mod-tools/open-user-info/${ ownerId }`) }>{ ownerName }</Text>
<span
className={ `inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium border ${ ownerPillClass }` }
title={ ownerInRoom ? LocalizeText('modtools.roominfo.owner.title.here') : LocalizeText('modtools.roominfo.owner.title.away') }>
<span className={ `inline-block w-2 h-2 rounded-full ${ ownerDotClass }` } />
{ ownerInRoom ? LocalizeText('modtools.roominfo.owner.here') : LocalizeText('modtools.roominfo.owner.away') }
</span>
<button
className="inline-flex items-center justify-center w-7 h-7 rounded text-zinc-500 hover:text-sky-700 hover:bg-sky-100 transition-colors shrink-0"
onClick={ refresh }
title={ LocalizeText('modtools.roominfo.refresh') }>
<FaSync size={ 12 } />
</button>
</div>
{/* Stat strip */}
<div className="flex gap-1.5">
<div className="flex flex-col items-center justify-center px-2 py-1.5 rounded border bg-sky-50 border-sky-200 text-sky-700 grow min-w-0">
<div className="flex items-center gap-1.5 text-[.7rem] uppercase tracking-wide opacity-70">
<FaUsers size={ 10 } /><span>{ LocalizeText('modtools.roominfo.stat.users') }</span>
</div>
<div className="flex items-center gap-1">
<Text bold className="opacity-60 shrink-0">Users in room:</Text>
<Text>{ usersInRoom }</Text>
<div className="text-lg font-semibold tabular-nums leading-tight">{ usersInRoom }</div>
</div>
<div className="flex flex-col items-center justify-center px-2 py-1.5 rounded border bg-zinc-50 border-zinc-200 text-zinc-700 grow min-w-0">
<div className="flex items-center gap-1.5 text-[.7rem] uppercase tracking-wide opacity-70">
<FaUserShield size={ 10 } /><span>{ LocalizeText('modtools.roominfo.stat.owner') }</span>
</div>
<div className="flex items-center gap-1">
<Text bold className="opacity-60 shrink-0">Owner here:</Text>
<Text className={ ownerInRoom ? 'text-green-700' : 'text-red-700' }>{ ownerInRoom ? 'Yes' : 'No' }</Text>
<div
className="text-sm font-semibold leading-tight truncate max-w-full underline cursor-pointer hover:text-sky-700"
onClick={ () => ownerId && CreateLinkEvent(`mod-tools/open-user-info/${ ownerId }`) }
title={ ownerName ? LocalizeText('modtools.roominfo.owner.open', [ 'username' ], [ ownerName ]) : '' }>
{ ownerName || '-' }
</div>
</Column>
<div className="flex flex-col gap-1 shrink-0">
<Button onClick={ event => TryVisitRoom(roomId) }>Visit Room</Button>
<Button onClick={ event => CreateLinkEvent(`mod-tools/open-room-chatlog/${ roomId }`) }>Chatlog</Button>
</div>
</div>
<Column className="bg-muted rounded p-2" gap={ 1 }>
<div className="flex items-center gap-2">
{/* Quick actions */}
<div className="grid grid-cols-2 gap-1.5">
<Button gap={ 1 } variant="secondary" onClick={ () => TryVisitRoom(roomId) }>
<FaSignInAlt size={ 12 } /> { LocalizeText('modtools.roominfo.button.visit') }
</Button>
<Button gap={ 1 } variant="secondary" onClick={ () => CreateLinkEvent(`mod-tools/open-room-chatlog/${ roomId }`) }>
<FaCommentDots size={ 12 } /> { LocalizeText('modtools.roominfo.button.chatlog') }
</Button>
</div>
{/* Moderate panel */}
<div className="flex flex-col gap-1.5 bg-amber-50 border border-amber-200 rounded p-2">
<div className="flex items-center gap-1.5 text-[.7rem] uppercase tracking-wide font-semibold text-amber-800">
<FaExclamationTriangle size={ 10 } /> { LocalizeText('modtools.roominfo.moderate.title') }
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input checked={ kickUsers } className="form-check-input" type="checkbox" onChange={ event => setKickUsers(event.target.checked) } />
<Text small>Kick everyone out</Text>
</div>
<div className="flex items-center gap-2">
<span>{ LocalizeText('modtools.roominfo.moderate.kick') }</span>
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input checked={ lockRoom } className="form-check-input" type="checkbox" onChange={ event => setLockRoom(event.target.checked) } />
<Text small>Enable the doorbell</Text>
</div>
<div className="flex items-center gap-2">
<span>{ LocalizeText('modtools.roominfo.moderate.doorbell') }</span>
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input checked={ changeRoomName } className="form-check-input" type="checkbox" onChange={ event => setChangeRoomName(event.target.checked) } />
<Text small>Change room name</Text>
<span>{ LocalizeText('modtools.roominfo.moderate.rename') }</span>
</label>
<textarea
className="min-h-[60px] px-2 py-1.5 rounded text-sm border border-amber-300 bg-white/70 focus:outline-none focus:ring-2 focus:ring-amber-400"
placeholder={ LocalizeText('modtools.roominfo.moderate.message.placeholder') }
value={ message }
onChange={ event => setMessage(event.target.value) }
/>
<div className="flex gap-1.5">
<Button className="grow" disabled={ !hasMessage || !isLoaded } gap={ 1 } variant="danger" onClick={ () => handleClick('send_message') }>
<FaBullhorn size={ 12 } /> { LocalizeText('modtools.roominfo.moderate.send.caution') }
</Button>
<Button className="grow" disabled={ !hasMessage || !isLoaded } gap={ 1 } variant="warning" onClick={ () => handleClick('alert_only') }>
<FaExclamationTriangle size={ 12 } /> { LocalizeText('modtools.roominfo.moderate.send.alert') }
</Button>
</div>
</Column>
<textarea className="min-h-[60px] px-2 py-1.5 rounded text-sm border border-black/10" placeholder="Type a mandatory message..." value={ message } onChange={ event => setMessage(event.target.value) }></textarea>
<div className="flex gap-2">
<Button className="grow" variant="danger" onClick={ event => handleClick('send_message') }>Send Caution</Button>
<Button className="grow" onClick={ event => handleClick('alert_only') }>Send Alert</Button>
</div>
</NitroCardContentView>
</NitroCardView>
@@ -1,7 +1,8 @@
import { CfhChatlogData, CfhChatlogEvent, GetCfhChatlogMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { SendMessageComposer } from '../../../../api';
import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { FaSpinner } from 'react-icons/fa';
import { LocalizeText, SendMessageComposer } from '../../../../api';
import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { useMessageEvent } from '../../../../hooks';
import { ChatlogView } from '../chatlog/ChatlogView';
@@ -31,10 +32,15 @@ export const CfhChatlogView: FC<CfhChatlogViewProps> = props =>
}, [ issueId ]);
return (
<NitroCardView className="nitro-mod-tools-chatlog" theme="primary-slim">
<NitroCardHeaderView headerText={ 'Issue Chatlog' } onCloseClick={ onCloseClick } />
<NitroCardContentView className="text-black">
{ chatlogData && <ChatlogView records={ [ chatlogData.chatRecord ] } /> }
<NitroCardView className="nitro-mod-tools-chatlog min-w-[460px] max-w-[520px] max-h-[500px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardHeaderView headerText={ LocalizeText('modtools.tickets.cfh.chatlog.title', [ 'issueId' ], [ issueId.toString() ]) } onCloseClick={ onCloseClick } />
<NitroCardContentView className="text-black" gap={ 1 }>
{ chatlogData
? <ChatlogView records={ [ chatlogData.chatRecord ] } />
: <div className="flex flex-col items-center justify-center gap-2 py-8 opacity-50 text-sm">
<FaSpinner className="animate-spin" size={ 22 } />
<span>{ LocalizeText('modtools.user.chatlog.loading') }</span>
</div> }
</NitroCardContentView>
</NitroCardView>
);
@@ -1,7 +1,8 @@
import { CloseIssuesMessageComposer, ReleaseIssuesMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useState } from 'react';
import { FaBan, FaCheck, FaCommentDots, FaExternalLinkAlt, FaSignOutAlt, FaTrashAlt } from 'react-icons/fa';
import { GetIssueCategoryName, LocalizeText, SendMessageComposer } from '../../../../api';
import { Button, Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { useModTools } from '../../../../hooks';
import { CfhChatlogView } from './CfhChatlogView';
@@ -11,76 +12,102 @@ interface IssueInfoViewProps
onIssueInfoClosed(issueId: number): void;
}
const Field: FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
<>
<dt className="opacity-60 whitespace-nowrap">{ label }</dt>
<dd className="m-0 break-words font-medium">{ children || <span className="opacity-40">-</span> }</dd>
</>
);
export const ModToolsIssueInfoView: FC<IssueInfoViewProps> = props =>
{
const { issueId = null, onIssueInfoClosed = null } = props;
const [ cfhChatlogOpen, setcfhChatlogOpen ] = useState(false);
const [ cfhChatlogOpen, setCfhChatlogOpen ] = useState(false);
const { tickets = [], openUserInfo = null } = useModTools();
const ticket = tickets.find(issue => (issue.issueId === issueId));
const releaseIssue = (issueId: number) =>
const releaseIssue = () =>
{
SendMessageComposer(new ReleaseIssuesMessageComposer([ issueId ]));
onIssueInfoClosed(issueId);
};
const closeIssue = (resolutionType: number) =>
{
SendMessageComposer(new CloseIssuesMessageComposer([ issueId ], resolutionType));
onIssueInfoClosed(issueId);
};
if(!ticket) return null;
return (
<>
<NitroCardView className="nitro-mod-tools-handle-issue" theme="primary-slim">
<NitroCardHeaderView headerText={ 'Resolving issue ' + issueId } onCloseClick={ () => onIssueInfoClosed(issueId) } />
<NitroCardContentView className="text-black">
<Text fontSize={ 4 }>Issue Information</Text>
<Grid overflow="auto">
<Column size={ 8 }>
<table className="table table-striped table-sm table-text-small text-black m-0">
<tbody>
<tr>
<th>Source</th>
<td>{ GetIssueCategoryName(ticket.categoryId) }</td>
</tr>
<tr>
<th>Category</th>
<td className="text-break">{ LocalizeText('help.cfh.topic.' + ticket.reportedCategoryId) }</td>
</tr>
<tr>
<th>Description</th>
<td className="text-break">{ ticket.message }</td>
</tr>
<tr>
<th>Caller</th>
<td>
<Text bold pointer underline onClick={ event => openUserInfo(ticket.reporterUserId) }>{ ticket.reporterUserName }</Text>
</td>
</tr>
<tr>
<th>Reported User</th>
<td>
<Text bold pointer underline onClick={ event => openUserInfo(ticket.reportedUserId) }>{ ticket.reportedUserName }</Text>
</td>
</tr>
</tbody>
</table>
</Column>
<Column gap={ 1 } size={ 4 }>
<Button variant="secondary" onClick={ () => setcfhChatlogOpen(!cfhChatlogOpen) }>Chatlog</Button>
<Button onClick={ event => closeIssue(CloseIssuesMessageComposer.RESOLUTION_USELESS) }>Close as useless</Button>
<Button variant="danger" onClick={ event => closeIssue(CloseIssuesMessageComposer.RESOLUTION_ABUSIVE) }>Close as abusive</Button>
<Button variant="success" onClick={ event => closeIssue(CloseIssuesMessageComposer.RESOLUTION_RESOLVED) }>Close as resolved</Button>
<Button variant="secondary" onClick={ event => releaseIssue(issueId) } >Release</Button>
</Column>
</Grid>
<NitroCardView className="nitro-mod-tools-handle-issue min-w-[440px] max-w-[500px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardHeaderView headerText={ LocalizeText('modtools.tickets.issue.title', [ 'issueId' ], [ issueId.toString() ]) } onCloseClick={ () => onIssueInfoClosed(issueId) } />
<NitroCardContentView className="text-black" gap={ 2 }>
{/* Issue header */}
<div className="flex items-center gap-2 bg-gradient-to-r from-amber-50 to-transparent rounded p-2 border border-amber-100">
<FaCommentDots className="text-amber-600 shrink-0" size={ 16 } />
<div className="flex flex-col grow min-w-0">
<div className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">{ LocalizeText('modtools.tickets.issue.label', [ 'issueId' ], [ issueId.toString() ]) }</div>
<div className="font-semibold leading-tight truncate">{ GetIssueCategoryName(ticket.categoryId) }</div>
</div>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border bg-white border-amber-200 text-amber-800">
{ LocalizeText('help.cfh.topic.' + ticket.reportedCategoryId) }
</span>
</div>
{/* Details */}
<div className="flex flex-col gap-1">
<div className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold border-b border-zinc-200 pb-1 mb-0.5">{ LocalizeText('modtools.tickets.issue.details') }</div>
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[.8rem] m-0">
<Field label={ LocalizeText('modtools.tickets.issue.field.source') }>{ GetIssueCategoryName(ticket.categoryId) }</Field>
<Field label={ LocalizeText('modtools.tickets.issue.field.category') }>{ LocalizeText('help.cfh.topic.' + ticket.reportedCategoryId) }</Field>
<Field label={ LocalizeText('modtools.tickets.issue.field.description') }>{ ticket.message }</Field>
<Field label={ LocalizeText('modtools.tickets.issue.field.caller') }>
<button
className="font-semibold text-sky-700 hover:text-sky-900 hover:underline inline-flex items-center gap-1"
onClick={ () => openUserInfo(ticket.reporterUserId) }>
{ ticket.reporterUserName } <FaExternalLinkAlt size={ 8 } className="opacity-60" />
</button>
</Field>
<Field label={ LocalizeText('modtools.tickets.issue.field.reported') }>
<button
className="font-semibold text-sky-700 hover:text-sky-900 hover:underline inline-flex items-center gap-1"
onClick={ () => openUserInfo(ticket.reportedUserId) }>
{ ticket.reportedUserName } <FaExternalLinkAlt size={ 8 } className="opacity-60" />
</button>
</Field>
</dl>
</div>
{/* Tools */}
<Button gap={ 1 } variant="secondary" onClick={ () => setCfhChatlogOpen(prev => !prev) }>
<FaCommentDots size={ 12 } /> { cfhChatlogOpen ? LocalizeText('modtools.tickets.issue.chatlog.close') : LocalizeText('modtools.tickets.issue.chatlog.view') }
</Button>
{/* Resolution buttons */}
<div className="flex flex-col gap-1.5 pt-1 border-t border-zinc-200">
<div className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">{ LocalizeText('modtools.tickets.issue.resolve.heading') }</div>
<div className="grid grid-cols-3 gap-1.5">
<Button gap={ 1 } variant="success" onClick={ () => closeIssue(CloseIssuesMessageComposer.RESOLUTION_RESOLVED) }>
<FaCheck size={ 11 } /> { LocalizeText('modtools.tickets.issue.resolve.resolved') }
</Button>
<Button gap={ 1 } variant="dark" onClick={ () => closeIssue(CloseIssuesMessageComposer.RESOLUTION_USELESS) }>
<FaTrashAlt size={ 11 } /> { LocalizeText('modtools.tickets.issue.resolve.useless') }
</Button>
<Button gap={ 1 } variant="danger" onClick={ () => closeIssue(CloseIssuesMessageComposer.RESOLUTION_ABUSIVE) }>
<FaBan size={ 11 } /> { LocalizeText('modtools.tickets.issue.resolve.abusive') }
</Button>
</div>
<Button gap={ 1 } variant="secondary" onClick={ releaseIssue }>
<FaSignOutAlt size={ 12 } /> { LocalizeText('modtools.tickets.issue.release') }
</Button>
</div>
</NitroCardContentView>
</NitroCardView>
{ cfhChatlogOpen &&
<CfhChatlogView issueId={ issueId } onCloseClick={ () => setcfhChatlogOpen(false) }/> }
<CfhChatlogView issueId={ issueId } onCloseClick={ () => setCfhChatlogOpen(false) } /> }
</>
);
};
@@ -1,7 +1,7 @@
import { IssueMessageData, ReleaseIssuesMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useRef } from 'react';
import { SendMessageComposer } from '../../../../api';
import { Button, Column, Grid } from '../../../../common';
import { FaClock, FaInbox, FaSignOutAlt, FaTools, FaUser } from 'react-icons/fa';
import { GetIssueCategoryName, LocalizeText, SendMessageComposer } from '../../../../api';
interface ModToolsMyIssuesTabViewProps
{
@@ -24,35 +24,45 @@ export const ModToolsMyIssuesTabView: FC<ModToolsMyIssuesTabViewProps> = props =
setTimeout(() => pendingReleasesRef.current.delete(issueId), 2000);
};
const isEmpty = !myIssues || myIssues.length === 0;
return (
<Column gap={ 0 } overflow="hidden">
<Column gap={ 2 }>
<Grid className="text-black font-bold border-bottom pb-1" gap={ 1 }>
<div className="col-span-2">Type</div>
<div className="col-span-3">Room/Player</div>
<div className="col-span-3">Opened</div>
<div className="col-span-2"></div>
<div className="col-span-2"></div>
</Grid>
</Column>
<Column className="striped-children" gap={ 0 } overflow="auto">
{ myIssues && (myIssues.length > 0) && myIssues.map(issue =>
{
return (
<Grid key={ issue.issueId } alignItems="center" className="text-black py-1 border-bottom" gap={ 1 }>
<div className="col-span-2">{ issue.categoryId }</div>
<div className="col-span-3">{ issue.reportedUserName }</div>
<div className="col-span-3">{ new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() }</div>
<div className="col-span-2">
<Button variant="primary" onClick={ event => handleIssue(issue.issueId) }>Handle</Button>
</div>
<div className="col-span-2">
<Button variant="danger" onClick={ () => releaseIssue(issue.issueId) }>Release</Button>
</div>
</Grid>
);
}) }
</Column>
</Column>
<div className="flex flex-col gap-1 overflow-hidden">
<div className="grid grid-cols-[100px_1fr_100px_90px_90px] gap-2 text-[.7rem] uppercase tracking-wide opacity-60 font-semibold border-b border-zinc-200 pb-1 px-1">
<div>{ LocalizeText('modtools.tickets.column.type') }</div>
<div className="flex items-center gap-1"><FaUser size={ 10 } /> { LocalizeText('modtools.tickets.column.reported') }</div>
<div className="flex items-center gap-1"><FaClock size={ 10 } /> { LocalizeText('modtools.tickets.column.opened') }</div>
<div></div>
<div></div>
</div>
{ isEmpty
? <div className="flex flex-col items-center justify-center gap-1 py-8 opacity-50 text-sm">
<FaInbox size={ 22 } />
<span>{ LocalizeText('modtools.tickets.empty.mine') }</span>
</div>
: <div className="flex flex-col overflow-auto">
{ myIssues.map(issue => (
<div key={ issue.issueId } className="grid grid-cols-[100px_1fr_100px_90px_90px] gap-2 items-center px-1 py-1.5 text-sm border-b border-zinc-100 even:bg-black/[0.02] hover:bg-sky-50/50 transition-colors">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border bg-sky-50 text-sky-800 border-sky-200 w-fit">
{ GetIssueCategoryName(issue.categoryId) }
</span>
<span className="font-medium truncate">{ issue.reportedUserName }</span>
<span className="font-mono text-[.75rem] opacity-70 tabular-nums">
{ new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() }
</span>
<button
className="inline-flex items-center justify-center gap-1 px-2 py-1 rounded text-xs font-medium bg-sky-600 text-white hover:bg-sky-700 transition-colors"
onClick={ () => handleIssue(issue.issueId) }>
<FaTools size={ 10 } /> { LocalizeText('modtools.tickets.action.handle') }
</button>
<button
className="inline-flex items-center justify-center gap-1 px-2 py-1 rounded text-xs font-medium bg-rose-600 text-white hover:bg-rose-700 transition-colors"
onClick={ () => releaseIssue(issue.issueId) }>
<FaSignOutAlt size={ 10 } /> { LocalizeText('modtools.tickets.action.release') }
</button>
</div>
)) }
</div> }
</div>
);
};
@@ -1,7 +1,7 @@
import { IssueMessageData, PickIssuesMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useRef } from 'react';
import { SendMessageComposer } from '../../../../api';
import { Button, Column, Grid } from '../../../../common';
import { FaClock, FaHandPointer, FaInbox, FaUser } from 'react-icons/fa';
import { GetIssueCategoryName, LocalizeText, SendMessageComposer } from '../../../../api';
interface ModToolsOpenIssuesTabViewProps
{
@@ -23,31 +23,39 @@ export const ModToolsOpenIssuesTabView: FC<ModToolsOpenIssuesTabViewProps> = pro
setTimeout(() => pendingPicksRef.current.delete(issueId), 2000);
};
const isEmpty = !openIssues || openIssues.length === 0;
return (
<Column gap={ 0 } overflow="hidden">
<Column gap={ 2 }>
<Grid className="text-black font-bold border-bottom pb-1" gap={ 1 }>
<div className="col-span-2">Type</div>
<div className="col-span-3">Room/Player</div>
<div className="col-span-4">Opened</div>
<div className="col-span-3"></div>
</Grid>
</Column>
<Column className="striped-children" gap={ 0 } overflow="auto">
{ openIssues && (openIssues.length > 0) && openIssues.map(issue =>
{
return (
<Grid key={ issue.issueId } alignItems="center" className="text-black py-1 border-bottom" gap={ 1 }>
<div className="col-span-2">{ issue.categoryId }</div>
<div className="col-span-3">{ issue.reportedUserName }</div>
<div className="col-span-4">{ new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() }</div>
<div className="col-span-3">
<Button variant="success" onClick={ () => pickIssue(issue.issueId) }>Pick Issue</Button>
</div>
</Grid>
);
}) }
</Column>
</Column>
<div className="flex flex-col gap-1 overflow-hidden">
<div className="grid grid-cols-[100px_1fr_100px_100px] gap-2 text-[.7rem] uppercase tracking-wide opacity-60 font-semibold border-b border-zinc-200 pb-1 px-1">
<div>{ LocalizeText('modtools.tickets.column.type') }</div>
<div className="flex items-center gap-1"><FaUser size={ 10 } /> { LocalizeText('modtools.tickets.column.reported') }</div>
<div className="flex items-center gap-1"><FaClock size={ 10 } /> { LocalizeText('modtools.tickets.column.opened') }</div>
<div></div>
</div>
{ isEmpty
? <div className="flex flex-col items-center justify-center gap-1 py-8 opacity-50 text-sm">
<FaInbox size={ 22 } />
<span>{ LocalizeText('modtools.tickets.empty.open') }</span>
</div>
: <div className="flex flex-col overflow-auto">
{ openIssues.map(issue => (
<div key={ issue.issueId } className="grid grid-cols-[100px_1fr_100px_100px] gap-2 items-center px-1 py-1.5 text-sm border-b border-zinc-100 even:bg-black/[0.02] hover:bg-amber-50/50 transition-colors">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border bg-amber-50 text-amber-800 border-amber-200 w-fit">
{ GetIssueCategoryName(issue.categoryId) }
</span>
<span className="font-medium truncate">{ issue.reportedUserName }</span>
<span className="font-mono text-[.75rem] opacity-70 tabular-nums">
{ new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() }
</span>
<button
className="inline-flex items-center justify-center gap-1 px-2 py-1 rounded text-xs font-medium bg-emerald-600 text-white hover:bg-emerald-700 transition-colors"
onClick={ () => pickIssue(issue.issueId) }>
<FaHandPointer size={ 10 } /> { LocalizeText('modtools.tickets.action.pick') }
</button>
</div>
)) }
</div> }
</div>
);
};
@@ -1,6 +1,7 @@
import { IssueMessageData } from '@nitrots/nitro-renderer';
import { FC } from 'react';
import { Column, Grid } from '../../../../common';
import { FaClock, FaInbox, FaUser, FaUserShield } from 'react-icons/fa';
import { GetIssueCategoryName, LocalizeText } from '../../../../api';
interface ModToolsPickedIssuesTabViewProps
{
@@ -10,30 +11,35 @@ interface ModToolsPickedIssuesTabViewProps
export const ModToolsPickedIssuesTabView: FC<ModToolsPickedIssuesTabViewProps> = props =>
{
const { pickedIssues = null } = props;
const isEmpty = !pickedIssues || pickedIssues.length === 0;
return (
<Column gap={ 0 } overflow="hidden">
<Column gap={ 2 }>
<Grid className="text-black font-bold border-bottom pb-1" gap={ 1 }>
<div className="col-span-2">Type</div>
<div className="col-span-3">Room/Player</div>
<div className="col-span-4">Opened</div>
<div className="col-span-3">Picker</div>
</Grid>
</Column>
<Column className="striped-children" gap={ 0 } overflow="auto">
{ pickedIssues && (pickedIssues.length > 0) && pickedIssues.map(issue =>
{
return (
<Grid key={ issue.issueId } alignItems="center" className="text-black py-1 border-bottom" gap={ 1 }>
<div className="col-span-2">{ issue.categoryId }</div>
<div className="col-span-3">{ issue.reportedUserName }</div>
<div className="col-span-4">{ new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() }</div>
<div className="col-span-3">{ issue.pickerUserName }</div>
</Grid>
);
}) }
</Column>
</Column>
<div className="flex flex-col gap-1 overflow-hidden">
<div className="grid grid-cols-[100px_1fr_100px_120px] gap-2 text-[.7rem] uppercase tracking-wide opacity-60 font-semibold border-b border-zinc-200 pb-1 px-1">
<div>{ LocalizeText('modtools.tickets.column.type') }</div>
<div className="flex items-center gap-1"><FaUser size={ 10 } /> { LocalizeText('modtools.tickets.column.reported') }</div>
<div className="flex items-center gap-1"><FaClock size={ 10 } /> { LocalizeText('modtools.tickets.column.opened') }</div>
<div className="flex items-center gap-1"><FaUserShield size={ 10 } /> { LocalizeText('modtools.tickets.column.picker') }</div>
</div>
{ isEmpty
? <div className="flex flex-col items-center justify-center gap-1 py-8 opacity-50 text-sm">
<FaInbox size={ 22 } />
<span>{ LocalizeText('modtools.tickets.empty.picked') }</span>
</div>
: <div className="flex flex-col overflow-auto">
{ pickedIssues.map(issue => (
<div key={ issue.issueId } className="grid grid-cols-[100px_1fr_100px_120px] gap-2 items-center px-1 py-1.5 text-sm border-b border-zinc-100 even:bg-black/[0.02]">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border bg-zinc-50 text-zinc-700 border-zinc-200 w-fit">
{ GetIssueCategoryName(issue.categoryId) }
</span>
<span className="font-medium truncate">{ issue.reportedUserName }</span>
<span className="font-mono text-[.75rem] opacity-70 tabular-nums">
{ new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() }
</span>
<span className="truncate font-medium opacity-80">{ issue.pickerUserName }</span>
</div>
)) }
</div> }
</div>
);
};
@@ -1,5 +1,7 @@
import { GetSessionDataManager, IssueMessageData } from '@nitrots/nitro-renderer';
import { FC, useState } from 'react';
import { FC, useMemo, useState } from 'react';
import { FaCheckSquare, FaListUl, FaUserCheck } from 'react-icons/fa';
import { LocalizeText } from '../../../../api';
import { NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../../../common';
import { useModTools } from '../../../../hooks';
import { ModToolsIssueInfoView } from './ModToolsIssueInfoView';
@@ -12,11 +14,30 @@ interface ModToolsTicketsViewProps
onCloseClick: () => void;
}
const TABS: string[] = [
'Open Issues',
'My Issues',
'Picked Issues'
];
interface TabBadgeProps
{
label: string;
count: number;
icon: React.ReactNode;
tone: 'amber' | 'sky' | 'zinc';
}
const TONE_MAP: Record<TabBadgeProps['tone'], string> = {
amber: 'bg-amber-500 text-white',
sky: 'bg-sky-500 text-white',
zinc: 'bg-zinc-400 text-white'
};
const TabLabel: FC<TabBadgeProps> = ({ label, count, icon, tone }) => (
<span className="inline-flex items-center gap-1.5">
<span className="opacity-80">{ icon }</span>
<span>{ label }</span>
{ count > 0 &&
<span className={ `inline-flex items-center justify-center min-w-[1.25rem] h-4 px-1 rounded-full text-[10px] font-semibold ${ TONE_MAP[tone] }` }>
{ count > 99 ? '99+' : count }
</span> }
</span>
);
export const ModToolsTicketsView: FC<ModToolsTicketsViewProps> = props =>
{
@@ -25,9 +46,15 @@ export const ModToolsTicketsView: FC<ModToolsTicketsViewProps> = props =>
const [ issueInfoWindows, setIssueInfoWindows ] = useState<number[]>([]);
const { tickets = [] } = useModTools();
const openIssues = tickets.filter(issue => issue.state === IssueMessageData.STATE_OPEN);
const myIssues = tickets.filter(issue => (issue.state === IssueMessageData.STATE_PICKED) && (issue.pickerUserId === GetSessionDataManager().userId));
const pickedIssues = tickets.filter(issue => issue.state === IssueMessageData.STATE_PICKED);
const { openIssues, myIssues, pickedIssues } = useMemo(() =>
{
const ownId = GetSessionDataManager()?.userId;
return {
openIssues: tickets.filter(issue => issue.state === IssueMessageData.STATE_OPEN),
myIssues: tickets.filter(issue => (issue.state === IssueMessageData.STATE_PICKED) && (issue.pickerUserId === ownId)),
pickedIssues: tickets.filter(issue => issue.state === IssueMessageData.STATE_PICKED)
};
}, [ tickets ]);
const closeIssue = (issueId: number) =>
{
@@ -56,32 +83,34 @@ export const ModToolsTicketsView: FC<ModToolsTicketsViewProps> = props =>
});
};
const CurrentTabComponent = () =>
const renderTab = () =>
{
switch(currentTab)
{
case 0: return <ModToolsOpenIssuesTabView openIssues={ openIssues }/>;
case 1: return <ModToolsMyIssuesTabView handleIssue={ handleIssue } myIssues={ myIssues }/>;
case 2: return <ModToolsPickedIssuesTabView pickedIssues={ pickedIssues }/>;
case 0: return <ModToolsOpenIssuesTabView openIssues={ openIssues } />;
case 1: return <ModToolsMyIssuesTabView handleIssue={ handleIssue } myIssues={ myIssues } />;
case 2: return <ModToolsPickedIssuesTabView pickedIssues={ pickedIssues } />;
}
return null;
};
return (
<>
<NitroCardView className="nitro-mod-tools-tickets">
<NitroCardHeaderView headerText={ 'Tickets' } onCloseClick={ onCloseClick } />
<NitroCardView className="nitro-mod-tools-tickets min-w-[520px] max-w-[640px] max-h-[520px]">
<NitroCardHeaderView headerText={ LocalizeText('modtools.tickets.title') } onCloseClick={ onCloseClick } />
<NitroCardTabsView>
{ TABS.map((tab, index) =>
{
return (<NitroCardTabsItemView key={ index } isActive={ (currentTab === index) } onClick={ event => setCurrentTab(index) }>
{ tab }
</NitroCardTabsItemView>);
}) }
<NitroCardTabsItemView isActive={ currentTab === 0 } onClick={ () => setCurrentTab(0) }>
<TabLabel label={ LocalizeText('modtools.tickets.tab.open') } count={ openIssues.length } icon={ <FaListUl size={ 10 } /> } tone="amber" />
</NitroCardTabsItemView>
<NitroCardTabsItemView isActive={ currentTab === 1 } onClick={ () => setCurrentTab(1) }>
<TabLabel label={ LocalizeText('modtools.tickets.tab.mine') } count={ myIssues.length } icon={ <FaUserCheck size={ 10 } /> } tone="sky" />
</NitroCardTabsItemView>
<NitroCardTabsItemView isActive={ currentTab === 2 } onClick={ () => setCurrentTab(2) }>
<TabLabel label={ LocalizeText('modtools.tickets.tab.picked') } count={ pickedIssues.length } icon={ <FaCheckSquare size={ 10 } /> } tone="zinc" />
</NitroCardTabsItemView>
</NitroCardTabsView>
<NitroCardContentView gap={ 1 }>
<CurrentTabComponent />
{ renderTab() }
</NitroCardContentView>
</NitroCardView>
{ issueInfoWindows && (issueInfoWindows.length > 0) && issueInfoWindows.map(issueId => <ModToolsIssueInfoView key={ issueId } issueId={ issueId } onIssueInfoClosed={ closeIssue } />) }

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