mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 23:16:21 +00:00
Merge branch 'Dev' into merge-duckie-main-2026-05-06
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { AddLinkEventTracker, GetCommunication, GetRoomSessionManager, HabboWebTools, ILinkEventTracker, RemoveLinkEventTracker, RoomSessionEvent } from '@nitrots/nitro-renderer';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { useNitroEvent } from '../hooks';
|
||||
import { useNitroEventReducer } from '../hooks';
|
||||
import { AchievementsView } from './achievements/AchievementsView';
|
||||
import { AvatarEditorView } from './avatar-editor';
|
||||
import { BadgeCreatorView } from './badge-creator';
|
||||
@@ -44,11 +44,33 @@ import { WiredCreatorToolsView } from './wired-tools/WiredCreatorToolsView';
|
||||
export const MainView: FC<{}> = props =>
|
||||
{
|
||||
const [ isReady, setIsReady ] = useState(false);
|
||||
const [ landingViewVisible, setLandingViewVisible ] = useState(true);
|
||||
const [ localizationVersion, setLocalizationVersion ] = useState(0);
|
||||
|
||||
useNitroEvent<RoomSessionEvent>(RoomSessionEvent.CREATED, event => setLandingViewVisible(false));
|
||||
useNitroEvent<RoomSessionEvent>(RoomSessionEvent.ENDED, event => setLandingViewVisible(event.openLandingView));
|
||||
// CREATED and ENDED can arrive out of order under flaky reconnects.
|
||||
// Treating them as two independent setters left landingViewVisible
|
||||
// contradicting the actual session state (stuck open in-room or
|
||||
// stuck closed at the hotel view). The reducer carries the active
|
||||
// session's roomId so a stale ENDED for a previous session is
|
||||
// ignored — only an ENDED matching the tracked session (or when
|
||||
// no session is active) is honored.
|
||||
const { landingViewVisible } = useNitroEventReducer<{ sessionId: number | null; landingViewVisible: boolean }, RoomSessionEvent>(
|
||||
[ RoomSessionEvent.CREATED, RoomSessionEvent.ENDED ],
|
||||
(state, event) =>
|
||||
{
|
||||
if(event.type === RoomSessionEvent.CREATED)
|
||||
{
|
||||
return { sessionId: event.session.roomId, landingViewVisible: false };
|
||||
}
|
||||
|
||||
if((state.sessionId !== null) && (event.session.roomId !== state.sessionId))
|
||||
{
|
||||
return state;
|
||||
}
|
||||
|
||||
return { sessionId: null, landingViewVisible: event.openLandingView };
|
||||
},
|
||||
{ sessionId: null, landingViewVisible: true }
|
||||
);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
@@ -132,7 +154,7 @@ export const MainView: FC<{}> = props =>
|
||||
<AvatarEffectsView />
|
||||
<AchievementsView />
|
||||
<NavigatorView />
|
||||
<NitrobubbleHiddenView />
|
||||
<NitrobubbleHiddenView />
|
||||
<InventoryView />
|
||||
<CatalogView />
|
||||
<FriendsView />
|
||||
|
||||
@@ -9,10 +9,10 @@ interface AdsenseConfig {
|
||||
fullWidthResponsive?: boolean;
|
||||
}
|
||||
|
||||
const ADSENSE_SCRIPT_ID = 'google-adsense-script';
|
||||
|
||||
const parsePublisherIdFromAdsTxt = (text: string): string | null => {
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const parsePublisherIdFromAdsTxt = (text: string): string | null =>
|
||||
{
|
||||
for (const rawLine of text.split(/\r?\n/))
|
||||
{
|
||||
const line = rawLine.split('#')[0].trim();
|
||||
if (!line) continue;
|
||||
const parts = line.split(',').map(part => part.trim());
|
||||
@@ -24,19 +24,8 @@ const parsePublisherIdFromAdsTxt = (text: string): string | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const ensureAdsenseScript = (publisherId: string): void => {
|
||||
if (typeof document === 'undefined') return;
|
||||
if (document.getElementById(ADSENSE_SCRIPT_ID)) return;
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.id = ADSENSE_SCRIPT_ID;
|
||||
script.async = true;
|
||||
script.crossOrigin = 'anonymous';
|
||||
script.src = `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-${ publisherId }`;
|
||||
document.head.appendChild(script);
|
||||
};
|
||||
|
||||
export const GoogleAdsView: FC<{}> = () => {
|
||||
export const GoogleAdsView: FC<{}> = () =>
|
||||
{
|
||||
const adsEnabled = GetConfigurationValue<boolean>('show.google.ads', false);
|
||||
const [ isOpen, setIsOpen ] = useState(false);
|
||||
const [ publisherId, setPublisherId ] = useState<string | null>(null);
|
||||
@@ -46,7 +35,8 @@ export const GoogleAdsView: FC<{}> = () => {
|
||||
const pushedRef = useRef(false);
|
||||
const autoOpenedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
useEffect(() =>
|
||||
{
|
||||
if (!adsEnabled) return;
|
||||
const handler = () => setIsOpen(prev => !prev);
|
||||
window.addEventListener('ads:toggle', handler);
|
||||
@@ -56,7 +46,8 @@ export const GoogleAdsView: FC<{}> = () => {
|
||||
// Auto-open once on initial mount (the login / landing stage).
|
||||
// Subsequent toggles are driven by the "ads:toggle" window event
|
||||
// (e.g. the Show Ad button in NitroSystemAlertView).
|
||||
useEffect(() => {
|
||||
useEffect(() =>
|
||||
{
|
||||
if (!adsEnabled) return;
|
||||
if (autoOpenedRef.current) return;
|
||||
autoOpenedRef.current = true;
|
||||
@@ -64,11 +55,14 @@ export const GoogleAdsView: FC<{}> = () => {
|
||||
return () => clearTimeout(t);
|
||||
}, [ adsEnabled ]);
|
||||
|
||||
useEffect(() => {
|
||||
useEffect(() =>
|
||||
{
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const [ adsTxtRes, configRes ] = await Promise.all([
|
||||
fetch('/ads.txt', { cache: 'no-cache' }),
|
||||
fetch(configFileUrl('adsense.json', true), { cache: 'no-cache' })
|
||||
@@ -87,44 +81,54 @@ export const GoogleAdsView: FC<{}> = () => {
|
||||
if (cancelled) return;
|
||||
setPublisherId(pubId);
|
||||
setConfig(cfg);
|
||||
} catch (err) {
|
||||
}
|
||||
catch (err)
|
||||
{
|
||||
if (!cancelled) setLoadError((err as Error).message);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
return () =>
|
||||
{
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !publisherId || !config) return;
|
||||
ensureAdsenseScript(publisherId);
|
||||
}, [ isOpen, publisherId, config ]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
useEffect(() =>
|
||||
{
|
||||
if (!isOpen)
|
||||
{
|
||||
pushedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!insRef.current || pushedRef.current) return;
|
||||
if (!publisherId || !config?.slot) return;
|
||||
|
||||
const tryPush = () => {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const tryPush = () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
const w = window as any;
|
||||
w.adsbygoogle = w.adsbygoogle || [];
|
||||
w.adsbygoogle.push({});
|
||||
pushedRef.current = true;
|
||||
} catch {
|
||||
}
|
||||
catch
|
||||
{
|
||||
// AdSense script may not be ready yet; retry once
|
||||
setTimeout(() => {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setTimeout(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
const w = window as any;
|
||||
w.adsbygoogle = w.adsbygoogle || [];
|
||||
w.adsbygoogle.push({});
|
||||
pushedRef.current = true;
|
||||
} catch { /* give up */ }
|
||||
}
|
||||
catch
|
||||
{ /* give up */ }
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
@@ -138,6 +142,11 @@ export const GoogleAdsView: FC<{}> = () => {
|
||||
|
||||
return (
|
||||
<NitroCardView className="nitro-google-ads" uniqueKey="google-ads" theme="primary">
|
||||
{ publisherId &&
|
||||
<script
|
||||
async
|
||||
crossOrigin="anonymous"
|
||||
src={ `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-${ publisherId }` } /> }
|
||||
<NitroCardHeaderView headerText="Sponsored" onCloseClick={ () => setIsOpen(false) } />
|
||||
<NitroCardContentView>
|
||||
<div className="flex items-center justify-center w-[300px] h-[250px] bg-white">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DetailedHTMLProps, HTMLAttributes, PropsWithChildren, forwardRef } from 'react';
|
||||
import { DetailedHTMLProps, HTMLAttributes, PropsWithChildren, Ref } from 'react';
|
||||
import { classNames } from '../../layout';
|
||||
|
||||
import arrowLeftIcon from '../../assets/images/avatareditor/arrow-left-icon.png';
|
||||
@@ -55,13 +55,14 @@ const ICON_MAP: Record<string, { normal: string; selected?: string }> = {
|
||||
'wa': { normal: waIcon, selected: waSelectedIcon },
|
||||
};
|
||||
|
||||
export const AvatarEditorIcon = forwardRef<HTMLDivElement, PropsWithChildren<{
|
||||
type AvatarEditorIconProps = PropsWithChildren<{
|
||||
icon: string;
|
||||
selected?: boolean;
|
||||
}> & DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>>((props, ref) =>
|
||||
{
|
||||
const { icon = null, selected = false, className = null, children, ...rest } = props;
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
}> & DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>;
|
||||
|
||||
export const AvatarEditorIcon = ({ ref, icon = null, selected = false, className = null, children, ...rest }: AvatarEditorIconProps) =>
|
||||
{
|
||||
const iconEntry = icon ? ICON_MAP[icon] : null;
|
||||
|
||||
if(!iconEntry) return null;
|
||||
@@ -77,6 +78,4 @@ export const AvatarEditorIcon = forwardRef<HTMLDivElement, PropsWithChildren<{
|
||||
{ children }
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
AvatarEditorIcon.displayName = 'AvatarEditorIcon';
|
||||
};
|
||||
|
||||
@@ -23,7 +23,10 @@ const findNearestColor = (hex: string, colors: IPartColor[]): IPartColor | null
|
||||
const cb = color.rgb & 0xFF;
|
||||
const dist = (r - cr) ** 2 + (g - cg) ** 2 + (b - cb) ** 2;
|
||||
|
||||
if(dist < minDist) { minDist = dist; nearest = color; }
|
||||
if(dist < minDist)
|
||||
{
|
||||
minDist = dist; nearest = color;
|
||||
}
|
||||
}
|
||||
|
||||
return nearest;
|
||||
@@ -40,7 +43,10 @@ export const AvatarEditorAdvancedColorView: FC<{
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
return () => { if(debounceRef.current) clearTimeout(debounceRef.current); };
|
||||
return () =>
|
||||
{
|
||||
if(debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const selectedColor = useMemo(() =>
|
||||
@@ -52,7 +58,7 @@ export const AvatarEditorAdvancedColorView: FC<{
|
||||
|
||||
const hexColor = useMemo(() =>
|
||||
ColorUtils.makeColorNumberHex((selectedColor?.rgb ?? 0) & 0xFFFFFF),
|
||||
[ selectedColor ]);
|
||||
[ selectedColor ]);
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AddLinkEventTracker, AvatarDirectionAngle, AvatarEffectActivatedComposer, GetConfiguration, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||
import { AddLinkEventTracker, AvatarDirectionAngle, AvatarEffectActivatedComposer, GetConfiguration, GetSessionDataManager, ILinkEventTracker, loadGamedata, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||
import { ChangeEvent, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { FaChevronLeft, FaChevronRight, FaSearch } from 'react-icons/fa';
|
||||
import { LocalizeText, SendMessageComposer } from '../../api';
|
||||
@@ -36,8 +36,8 @@ export const AvatarEffectsView: FC<{}> = () =>
|
||||
|
||||
switch(parts[1])
|
||||
{
|
||||
case 'show': setIsVisible(true); return;
|
||||
case 'hide': setIsVisible(false); return;
|
||||
case 'show': setIsVisible(true); return;
|
||||
case 'hide': setIsVisible(false); return;
|
||||
case 'toggle': setIsVisible(prev => !prev); return;
|
||||
}
|
||||
},
|
||||
@@ -65,9 +65,11 @@ export const AvatarEffectsView: FC<{}> = () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const response = await fetch(url);
|
||||
if(!response.ok) throw new Error(`HTTP ${ response.status }`);
|
||||
const json = await response.json();
|
||||
// The effectmap is served either as a single JSON file or as a
|
||||
// tiered directory with core/custom/seasonal manifests using
|
||||
// JSON5 syntax (// comments allowed). loadGamedata picks the
|
||||
// right mode for us and merges tiers.
|
||||
const json = await loadGamedata<{ effects?: EffectMapEntry[] }>(url);
|
||||
if(cancelled) return;
|
||||
|
||||
const list: EffectMapEntry[] = Array.isArray(json?.effects)
|
||||
@@ -83,7 +85,10 @@ export const AvatarEffectsView: FC<{}> = () =>
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
return () =>
|
||||
{
|
||||
cancelled = true;
|
||||
};
|
||||
}, [ isVisible, effects.length, loadError ]);
|
||||
|
||||
const session = GetSessionDataManager();
|
||||
@@ -108,6 +113,13 @@ export const AvatarEffectsView: FC<{}> = () =>
|
||||
setIsVisible(false);
|
||||
}, [ selectedId ]);
|
||||
|
||||
const removeCurrentEffect = useCallback(() =>
|
||||
{
|
||||
SendMessageComposer(new AvatarEffectActivatedComposer(0));
|
||||
setSelectedId(0);
|
||||
setIsVisible(false);
|
||||
}, []);
|
||||
|
||||
const onClose = useCallback(() => setIsVisible(false), []);
|
||||
|
||||
const filteredEffects = useMemo(() =>
|
||||
@@ -191,9 +203,14 @@ export const AvatarEffectsView: FC<{}> = () =>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<Button variant="success" disabled={ !selectedId } onClick={ applySelectedEffect } className="w-full mt-2">
|
||||
{ LocalizeText('inventory.effects.activate') || 'Use' }
|
||||
</Button>
|
||||
<div className="flex gap-1 mt-2">
|
||||
<Button variant="success" disabled={ !selectedId } onClick={ applySelectedEffect } className="flex-1">
|
||||
{ LocalizeText('inventory.effects.activate') || 'Use effect' }
|
||||
</Button>
|
||||
<Button variant="danger" onClick={ removeCurrentEffect } className="flex-1">
|
||||
{ LocalizeText('inventory.effects.remove') || 'Remove effect' }
|
||||
</Button>
|
||||
</div>
|
||||
</Column>
|
||||
<Column overflow="hidden" className="flex-1 min-h-0">
|
||||
<div className="relative">
|
||||
|
||||
@@ -18,12 +18,42 @@ interface BackgroundsViewProps {
|
||||
setSelectedOverlay: Dispatch<SetStateAction<number>>;
|
||||
selectedCardBackground: number;
|
||||
setSelectedCardBackground: Dispatch<SetStateAction<number>>;
|
||||
selectedBorder: number;
|
||||
setSelectedBorder: Dispatch<SetStateAction<number>>;
|
||||
}
|
||||
|
||||
const TABS = ['backgrounds', 'stands', 'overlays', 'cards'] as const;
|
||||
const TABS = ['backgrounds', 'stands', 'overlays', 'cards', 'borders'] as const;
|
||||
type TabType = typeof TABS[number];
|
||||
|
||||
type RemoteData = Partial<Record<'backgrounds.data' | 'stands.data' | 'overlays.data' | 'cards.data', any[]>>;
|
||||
type RemoteData = Partial<Record<'backgrounds.data' | 'stands.data' | 'overlays.data' | 'cards.data' | 'borders.data', any[]>>;
|
||||
|
||||
// Module-scoped cache so repeated mounts don't refetch the same JSON.
|
||||
// Not a Promise — we deliberately don't expose anything that could be
|
||||
// passed to React's `use()` hook. Suspending here unmounts the parent
|
||||
// room tree (no <Suspense> boundary upstream), which orphans the Pixi
|
||||
// canvas and leaves the room rendered as a black square until another
|
||||
// state change forces a re-render.
|
||||
let cachedBackgroundsData: RemoteData | null = null;
|
||||
let inflightBackgroundsFetch: Promise<RemoteData | null> | null = null;
|
||||
|
||||
const loadBackgroundsData = (): Promise<RemoteData | null> =>
|
||||
{
|
||||
if(cachedBackgroundsData) return Promise.resolve(cachedBackgroundsData);
|
||||
if(inflightBackgroundsFetch) return inflightBackgroundsFetch;
|
||||
|
||||
inflightBackgroundsFetch = fetch(configFileUrl('infostand_backgrounds.json'), { credentials: 'omit' })
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(json =>
|
||||
{
|
||||
const result = (json && typeof json === 'object') ? json as RemoteData : null;
|
||||
cachedBackgroundsData = result;
|
||||
return result;
|
||||
})
|
||||
.catch(() => null)
|
||||
.finally(() => { inflightBackgroundsFetch = null; });
|
||||
|
||||
return inflightBackgroundsFetch;
|
||||
};
|
||||
|
||||
export const BackgroundsView: FC<BackgroundsViewProps> = ({
|
||||
setIsVisible,
|
||||
@@ -34,28 +64,35 @@ export const BackgroundsView: FC<BackgroundsViewProps> = ({
|
||||
selectedOverlay,
|
||||
setSelectedOverlay,
|
||||
selectedCardBackground,
|
||||
setSelectedCardBackground
|
||||
}) => {
|
||||
setSelectedCardBackground,
|
||||
selectedBorder,
|
||||
setSelectedBorder
|
||||
}) =>
|
||||
{
|
||||
const [activeTab, setActiveTab] = useState<TabType>('backgrounds');
|
||||
const [remoteData, setRemoteData] = useState<RemoteData | null>(null);
|
||||
const [remoteData, setRemoteData] = useState<RemoteData | null>(cachedBackgroundsData);
|
||||
const { roomSession } = useRoom();
|
||||
|
||||
useEffect(() => {
|
||||
useEffect(() =>
|
||||
{
|
||||
if(remoteData) return;
|
||||
let cancelled = false;
|
||||
fetch(configFileUrl('infostand_backgrounds.json'), { credentials: 'omit' })
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(json => { if(!cancelled && json && typeof json === 'object') setRemoteData(json as RemoteData); })
|
||||
.catch(() => {});
|
||||
loadBackgroundsData().then(data =>
|
||||
{
|
||||
if(!cancelled && data) setRemoteData(data);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
}, [remoteData]);
|
||||
|
||||
const processData = useCallback((configData: any[], idField: string): ItemData[] => {
|
||||
const processData = useCallback((configData: any[], idField: string): ItemData[] =>
|
||||
{
|
||||
if (!configData?.length) return [];
|
||||
|
||||
return configData.map(item => ({ id: typeof item === 'number' ? item : item[idField] }));
|
||||
}, []);
|
||||
|
||||
const readData = useCallback((key: 'backgrounds.data' | 'stands.data' | 'overlays.data' | 'cards.data'): any[] => {
|
||||
const readData = useCallback((key: 'backgrounds.data' | 'stands.data' | 'overlays.data' | 'cards.data' | 'borders.data'): any[] =>
|
||||
{
|
||||
const fromRemote = remoteData?.[key];
|
||||
if(Array.isArray(fromRemote)) return fromRemote;
|
||||
return GetOptionalConfigurationValue<any[]>(key, []) || [];
|
||||
@@ -65,20 +102,28 @@ export const BackgroundsView: FC<BackgroundsViewProps> = ({
|
||||
backgrounds: processData(readData('backgrounds.data'), 'backgroundId'),
|
||||
stands: processData(readData('stands.data'), 'standId'),
|
||||
overlays: processData(readData('overlays.data'), 'overlayId'),
|
||||
cards: processData(readData('cards.data').length ? readData('cards.data') : readData('backgrounds.data'), 'backgroundId')
|
||||
cards: processData(readData('cards.data').length ? readData('cards.data') : readData('backgrounds.data'), 'backgroundId'),
|
||||
borders: processData(readData('borders.data'), 'borderId')
|
||||
}), [processData, readData]);
|
||||
|
||||
const handleSelection = useCallback((id: number) => {
|
||||
const handleSelection = useCallback((id: number) =>
|
||||
{
|
||||
if (!roomSession) return;
|
||||
|
||||
const setters = { backgrounds: setSelectedBackground, stands: setSelectedStand, overlays: setSelectedOverlay, cards: setSelectedCardBackground };
|
||||
const setters = { backgrounds: setSelectedBackground, stands: setSelectedStand, overlays: setSelectedOverlay, cards: setSelectedCardBackground, borders: setSelectedBorder };
|
||||
|
||||
const currentValues = { backgrounds: selectedBackground, stands: selectedStand, overlays: selectedOverlay, cards: selectedCardBackground };
|
||||
const currentValues = { backgrounds: selectedBackground, stands: selectedStand, overlays: selectedOverlay, cards: selectedCardBackground, borders: selectedBorder };
|
||||
|
||||
setters[activeTab](id);
|
||||
const newValues = { ...currentValues, [activeTab]: id };
|
||||
roomSession.sendBackgroundMessage( newValues.backgrounds, newValues.stands, newValues.overlays, newValues.cards );
|
||||
}, [activeTab, roomSession, selectedBackground, selectedStand, selectedOverlay, selectedCardBackground, setSelectedBackground, setSelectedStand, setSelectedOverlay, setSelectedCardBackground]);
|
||||
roomSession.sendBackgroundMessage( newValues.backgrounds, newValues.stands, newValues.overlays, newValues.cards, newValues.borders );
|
||||
}, [activeTab, roomSession, selectedBackground, selectedStand, selectedOverlay, selectedCardBackground, selectedBorder, setSelectedBackground, setSelectedStand, setSelectedOverlay, setSelectedCardBackground, setSelectedBorder]);
|
||||
|
||||
const itemTypeFor = (tab: TabType): string => {
|
||||
if(tab === 'cards') return 'card-background';
|
||||
if(tab === 'borders') return 'border';
|
||||
return tab.slice(0, -1);
|
||||
};
|
||||
|
||||
const renderItem = useCallback((item: ItemData, type: string) => (
|
||||
<Flex
|
||||
@@ -89,7 +134,11 @@ export const BackgroundsView: FC<BackgroundsViewProps> = ({
|
||||
>
|
||||
<Base
|
||||
className={`profile-${type} ${type}-${item.id}`}
|
||||
style={type === 'card-background' ? { width: 60, height: 80, borderRadius: 4 } : undefined}
|
||||
style={
|
||||
type === 'card-background' ? { width: 60, height: 80, borderRadius: 4 }
|
||||
: type === 'border' ? { width: 60, height: 76, backgroundSize: 'contain', backgroundRepeat: 'no-repeat', backgroundPosition: 'center' }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Flex>
|
||||
), [handleSelection]);
|
||||
@@ -111,7 +160,7 @@ export const BackgroundsView: FC<BackgroundsViewProps> = ({
|
||||
<NitroCardContentView gap={1}>
|
||||
<Text bold center>Select an Option</Text>
|
||||
<Grid gap={1} columnCount={7} overflow="auto">
|
||||
{allData[activeTab].map(item => renderItem(item, activeTab === 'cards' ? 'card-background' : activeTab.slice(0, -1)))}
|
||||
{allData[activeTab].map(item => renderItem(item, itemTypeFor(activeTab)))}
|
||||
</Grid>
|
||||
</NitroCardContentView>
|
||||
</NitroCardView>
|
||||
|
||||
@@ -12,7 +12,8 @@ const t = (key: string, fallback: string, params?: string[], replacements?: stri
|
||||
const value = LocalizeText(key, params ?? null, replacements ?? null);
|
||||
if(value && value !== key) return value;
|
||||
}
|
||||
catch {}
|
||||
catch
|
||||
{}
|
||||
|
||||
if(!params || !replacements) return fallback;
|
||||
let out = fallback;
|
||||
@@ -38,8 +39,8 @@ const PALETTE: number[] = [
|
||||
const currencyName = (type: number): string =>
|
||||
{
|
||||
if(type === -1) return 'credits';
|
||||
if(type === 0) return 'duckets';
|
||||
if(type === 5) return 'diamonds';
|
||||
if(type === 0) return 'duckets';
|
||||
if(type === 5) return 'diamonds';
|
||||
return `currency #${ type }`;
|
||||
};
|
||||
|
||||
@@ -58,14 +59,14 @@ const floodFill = (grid: Uint32Array, w: number, h: number, startX: number, star
|
||||
const stack: number[] = [ startIdx ];
|
||||
while(stack.length)
|
||||
{
|
||||
const idx = stack.pop() as number;
|
||||
const idx = stack.pop();
|
||||
if(next[idx] !== target) continue;
|
||||
next[idx] = replacement;
|
||||
const x = idx % w;
|
||||
const y = (idx - x) / w;
|
||||
if(x > 0) stack.push(idx - 1);
|
||||
if(x > 0) stack.push(idx - 1);
|
||||
if(x < w - 1) stack.push(idx + 1);
|
||||
if(y > 0) stack.push(idx - w);
|
||||
if(y > 0) stack.push(idx - w);
|
||||
if(y < h - 1) stack.push(idx + w);
|
||||
}
|
||||
return next;
|
||||
@@ -119,7 +120,7 @@ const gridToPngBase64 = async (grid: Uint32Array): Promise<{ b64: string; bytes:
|
||||
{
|
||||
const argb = grid[i];
|
||||
const o = i * 4;
|
||||
image.data[o] = (argb >>> 16) & 0xff;
|
||||
image.data[o] = (argb >>> 16) & 0xff;
|
||||
image.data[o + 1] = (argb >>> 8) & 0xff;
|
||||
image.data[o + 2] = argb & 0xff;
|
||||
image.data[o + 3] = (argb >>> 24) & 0xff;
|
||||
@@ -157,12 +158,18 @@ const loadGridFromUrl = (url: string): Promise<Uint32Array> =>
|
||||
{
|
||||
const o = i * 4;
|
||||
const a = data[o + 3];
|
||||
if(a === 0) { grid[i] = 0; continue; }
|
||||
if(a === 0)
|
||||
{
|
||||
grid[i] = 0; continue;
|
||||
}
|
||||
grid[i] = ((a & 0xff) << 24) | ((data[o] & 0xff) << 16) | ((data[o + 1] & 0xff) << 8) | (data[o + 2] & 0xff);
|
||||
}
|
||||
resolve(grid);
|
||||
}
|
||||
catch(err) { reject(err); }
|
||||
catch(err)
|
||||
{
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
image.onerror = () => reject(new Error('Could not load badge image (CORS?).'));
|
||||
image.src = url + (url.includes('?') ? '&' : '?') + 't=' + Date.now();
|
||||
@@ -216,8 +223,8 @@ export const BadgeCreatorView: FC<{}> = () =>
|
||||
if(parts.length < 2) return;
|
||||
switch(parts[1])
|
||||
{
|
||||
case 'show': setIsVisible(true); return;
|
||||
case 'hide': setIsVisible(false); return;
|
||||
case 'show': setIsVisible(true); return;
|
||||
case 'hide': setIsVisible(false); return;
|
||||
case 'toggle': setIsVisible(v => !v); return;
|
||||
case 'edit':
|
||||
if(!parts[2]) return;
|
||||
@@ -232,7 +239,13 @@ export const BadgeCreatorView: FC<{}> = () =>
|
||||
return () => RemoveLinkEventTracker(tracker);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { if(isVisible) { refresh(); ensureCustomBadgeTexts(); } }, [ isVisible, refresh ]);
|
||||
useEffect(() =>
|
||||
{
|
||||
if(isVisible)
|
||||
{
|
||||
refresh(); ensureCustomBadgeTexts();
|
||||
}
|
||||
}, [ isVisible, refresh ]);
|
||||
|
||||
const resetEditor = useCallback(() =>
|
||||
{
|
||||
@@ -316,9 +329,9 @@ export const BadgeCreatorView: FC<{}> = () =>
|
||||
{
|
||||
const v = grid[i];
|
||||
const o = i * 4;
|
||||
buffer[o] = (v >>> 16) & 0xff;
|
||||
buffer[o + 1] = (v >>> 8) & 0xff;
|
||||
buffer[o + 2] = v & 0xff;
|
||||
buffer[o] = (v >>> 16) & 0xff;
|
||||
buffer[o + 1] = (v >>> 8) & 0xff;
|
||||
buffer[o + 2] = v & 0xff;
|
||||
buffer[o + 3] = (v >>> 24) & 0xff;
|
||||
}
|
||||
ctx.putImageData(image, 0, 0);
|
||||
@@ -365,7 +378,10 @@ export const BadgeCreatorView: FC<{}> = () =>
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const stopDrag = () => { isDraggingRef.current = false; };
|
||||
const stopDrag = () =>
|
||||
{
|
||||
isDraggingRef.current = false;
|
||||
};
|
||||
window.addEventListener('mouseup', stopDrag);
|
||||
return () => window.removeEventListener('mouseup', stopDrag);
|
||||
}, []);
|
||||
@@ -385,7 +401,10 @@ export const BadgeCreatorView: FC<{}> = () =>
|
||||
const handleSave = useCallback(async () =>
|
||||
{
|
||||
if(submitting) return;
|
||||
if(isEmpty) { setError(t('badgecreator.error.empty', 'Draw something first.')); return; }
|
||||
if(isEmpty)
|
||||
{
|
||||
setError(t('badgecreator.error.empty', 'Draw something first.')); return;
|
||||
}
|
||||
if(!editingBadgeId && !canCreateMore)
|
||||
{
|
||||
setError(t('badgecreator.error.limit', 'You already have %max% custom badges.', [ 'max' ], [ String(maxBadges) ]));
|
||||
@@ -506,7 +525,10 @@ export const BadgeCreatorView: FC<{}> = () =>
|
||||
<button
|
||||
key={ idx }
|
||||
type="button"
|
||||
onClick={ () => { setSelectedColor(color); setTool('paint'); } }
|
||||
onClick={ () =>
|
||||
{
|
||||
setSelectedColor(color); setTool('paint');
|
||||
} }
|
||||
title={ isTransparent ? 'Transparent' : argbToCss(color) }
|
||||
style={ {
|
||||
width: 22,
|
||||
|
||||
@@ -19,7 +19,7 @@ export const CameraWidgetCaptureView: FC<CameraWidgetCaptureViewProps> = props =
|
||||
const { onClose = null, onEdit = null, onDelete = null } = props;
|
||||
const { cameraRoll = null, setCameraRoll = null, selectedPictureIndex = -1, setSelectedPictureIndex = null } = useCamera();
|
||||
const { simpleAlert = null } = useNotification();
|
||||
const elementRef = useRef<HTMLDivElement>();
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectedPicture = ((selectedPictureIndex > -1) ? cameraRoll[selectedPictureIndex] : null);
|
||||
|
||||
|
||||
@@ -10,40 +10,46 @@ export interface CameraWidgetShowPhotoViewProps {
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const CameraWidgetShowPhotoView: FC<CameraWidgetShowPhotoViewProps> = props => {
|
||||
export const CameraWidgetShowPhotoView: FC<CameraWidgetShowPhotoViewProps> = props =>
|
||||
{
|
||||
const { currentIndex = -1, currentPhotos = null, onClick = null } = props;
|
||||
const [imageIndex, setImageIndex] = useState(0);
|
||||
|
||||
const currentImage = currentPhotos && currentPhotos.length ? currentPhotos[imageIndex] : null;
|
||||
|
||||
const next = () => {
|
||||
setImageIndex(prevValue => {
|
||||
const next = () =>
|
||||
{
|
||||
setImageIndex(prevValue =>
|
||||
{
|
||||
let newIndex = prevValue + 1;
|
||||
if (newIndex >= currentPhotos.length) newIndex = 0;
|
||||
return newIndex;
|
||||
});
|
||||
};
|
||||
|
||||
const previous = () => {
|
||||
setImageIndex(prevValue => {
|
||||
const previous = () =>
|
||||
{
|
||||
setImageIndex(prevValue =>
|
||||
{
|
||||
let newIndex = prevValue - 1;
|
||||
if (newIndex < 0) newIndex = currentPhotos.length - 1;
|
||||
return newIndex;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
useEffect(() =>
|
||||
{
|
||||
setImageIndex(currentIndex);
|
||||
}, [currentIndex]);
|
||||
|
||||
if (!currentImage) return null;
|
||||
|
||||
const getUserData = (roomId: number, objectId: number, type: string): number | string =>
|
||||
|
||||
const getUserData = (roomId: number, objectId: number, type: string): number | string =>
|
||||
{
|
||||
const roomObject = GetRoomEngine().getRoomObject(roomId, objectId, RoomObjectCategory.WALL);
|
||||
if (!roomObject) return;
|
||||
return type == 'username' ? roomObject.model.getValue<number>(RoomObjectVariable.FURNITURE_OWNER_NAME) : roomObject.model.getValue<number>(RoomObjectVariable.FURNITURE_OWNER_ID);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
@@ -53,13 +59,13 @@ export const CameraWidgetShowPhotoView: FC<CameraWidgetShowPhotoViewProps> = pro
|
||||
{currentImage.m && currentImage.m.length && <Text center>{currentImage.m}</Text>}
|
||||
<div className="flex items-center center justify-between">
|
||||
<Text>{currentImage.n || ''}</Text>
|
||||
<Text onClick={() => GetUserProfile(Number(getUserData(currentImage.s, Number(currentImage.u), 'id')))}> { getUserData(currentImage.s, Number(currentImage.u), 'username') } </Text>
|
||||
<Text className="cursor-pointer" onClick={() => GetUserProfile(currentImage.oi)}>{currentImage.o}</Text>
|
||||
<Text>{new Date(currentImage.t * 1000).toLocaleDateString()}</Text>
|
||||
<Text onClick={() => GetUserProfile(Number(getUserData(currentImage.s, Number(currentImage.u), 'id')))}> { getUserData(currentImage.s, Number(currentImage.u), 'username') } </Text>
|
||||
<Text className="cursor-pointer" onClick={() => GetUserProfile(currentImage.oi)}>{currentImage.o}</Text>
|
||||
<Text>{new Date(currentImage.t * 1000).toLocaleDateString()}</Text>
|
||||
</div>
|
||||
{currentPhotos.length > 1 && (
|
||||
<Flex className="picture-preview-buttons">
|
||||
<FaArrowLeft onClick={previous} />
|
||||
<FaArrowLeft onClick={previous} />
|
||||
<FaArrowRight className="cursor-pointer"onClick={next} />
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
@@ -16,7 +16,8 @@ export interface CameraWidgetEditorViewProps {
|
||||
|
||||
const TABS: string[] = [ CameraEditorTabs.COLORMATRIX, CameraEditorTabs.COMPOSITE ];
|
||||
|
||||
export const CameraWidgetEditorView: FC<CameraWidgetEditorViewProps> = props => {
|
||||
export const CameraWidgetEditorView: FC<CameraWidgetEditorViewProps> = props =>
|
||||
{
|
||||
const { picture = null, availableEffects = null, myLevel = 1, onClose = null, onCancel = null, onCheckout = null } = props;
|
||||
const [ currentTab, setCurrentTab ] = useState(TABS[0]);
|
||||
const [ selectedEffectName, setSelectedEffectName ] = useState<string>(null);
|
||||
@@ -35,37 +36,45 @@ export const CameraWidgetEditorView: FC<CameraWidgetEditorViewProps> = props =>
|
||||
img.src = picture.imageUrl;
|
||||
}, [ picture ]);
|
||||
|
||||
const getColorMatrixEffects = useMemo(() => {
|
||||
const getColorMatrixEffects = useMemo(() =>
|
||||
{
|
||||
return availableEffects.filter(effect => effect.colorMatrix);
|
||||
}, [ availableEffects ]);
|
||||
|
||||
const getCompositeEffects = useMemo(() => {
|
||||
const getCompositeEffects = useMemo(() =>
|
||||
{
|
||||
return availableEffects.filter(effect => effect.texture);
|
||||
}, [ availableEffects ]);
|
||||
|
||||
const getEffectList = useCallback(() => {
|
||||
const getEffectList = useCallback(() =>
|
||||
{
|
||||
return currentTab === CameraEditorTabs.COLORMATRIX ? getColorMatrixEffects : getCompositeEffects;
|
||||
}, [ currentTab, getColorMatrixEffects, getCompositeEffects ]);
|
||||
|
||||
const getSelectedEffectIndex = useCallback((name: string) => {
|
||||
const getSelectedEffectIndex = useCallback((name: string) =>
|
||||
{
|
||||
if (!name || !name.length || !selectedEffects || !selectedEffects.length) return -1;
|
||||
return selectedEffects.findIndex(effect => effect.effect.name === name);
|
||||
}, [ selectedEffects ]);
|
||||
|
||||
const getCurrentEffectIndex = useMemo(() => {
|
||||
const getCurrentEffectIndex = useMemo(() =>
|
||||
{
|
||||
return getSelectedEffectIndex(selectedEffectName);
|
||||
}, [ selectedEffectName, getSelectedEffectIndex ]);
|
||||
|
||||
const getCurrentEffect = useMemo(() => {
|
||||
const getCurrentEffect = useMemo(() =>
|
||||
{
|
||||
if (!selectedEffectName) return null;
|
||||
return selectedEffects[getCurrentEffectIndex] || null;
|
||||
}, [ selectedEffectName, getCurrentEffectIndex, selectedEffects ]);
|
||||
|
||||
const setSelectedEffectAlpha = useCallback((alpha: number) => {
|
||||
const setSelectedEffectAlpha = useCallback((alpha: number) =>
|
||||
{
|
||||
const index = getCurrentEffectIndex;
|
||||
if (index === -1) return;
|
||||
|
||||
setSelectedEffects(prevValue => {
|
||||
setSelectedEffects(prevValue =>
|
||||
{
|
||||
const clone = [ ...prevValue ];
|
||||
const currentEffect = clone[index];
|
||||
clone[index] = new RoomCameraWidgetSelectedEffect(currentEffect.effect, alpha);
|
||||
@@ -73,8 +82,10 @@ export const CameraWidgetEditorView: FC<CameraWidgetEditorViewProps> = props =>
|
||||
});
|
||||
}, [ getCurrentEffectIndex ]);
|
||||
|
||||
const processAction = useCallback((type: string, effectName: string = null) => {
|
||||
switch (type) {
|
||||
const processAction = useCallback((type: string, effectName: string = null) =>
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case 'close':
|
||||
onClose();
|
||||
return;
|
||||
@@ -102,7 +113,8 @@ export const CameraWidgetEditorView: FC<CameraWidgetEditorViewProps> = props =>
|
||||
const existingIndex = getSelectedEffectIndex(effectName);
|
||||
if (existingIndex === -1) return;
|
||||
|
||||
setSelectedEffects(prevValue => {
|
||||
setSelectedEffects(prevValue =>
|
||||
{
|
||||
const clone = [ ...prevValue ];
|
||||
clone.splice(existingIndex, 1);
|
||||
return clone;
|
||||
@@ -141,10 +153,12 @@ export const CameraWidgetEditorView: FC<CameraWidgetEditorViewProps> = props =>
|
||||
}
|
||||
}, [ availableEffects, selectedEffectName, currentPictureUrl, getSelectedEffectIndex, onCancel, onCheckout, onClose ]);
|
||||
|
||||
useEffect(() => {
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!stableTexture) return;
|
||||
|
||||
const processThumbnails = async () => {
|
||||
const processThumbnails = async () =>
|
||||
{
|
||||
const renderedEffects = await Promise.all(
|
||||
availableEffects.map(effect =>
|
||||
GetRoomCameraWidgetManager().applyEffects(stableTexture, [ new RoomCameraWidgetSelectedEffect(effect, 1) ], false)
|
||||
@@ -155,24 +169,28 @@ export const CameraWidgetEditorView: FC<CameraWidgetEditorViewProps> = props =>
|
||||
processThumbnails();
|
||||
}, [ stableTexture, availableEffects ]);
|
||||
|
||||
useEffect(() => {
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!stableTexture) return;
|
||||
|
||||
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
|
||||
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
debounceTimerRef.current = setTimeout(() =>
|
||||
{
|
||||
const id = ++requestIdRef.current;
|
||||
|
||||
GetRoomCameraWidgetManager()
|
||||
.applyEffects(stableTexture, selectedEffects, false)
|
||||
.then(imageElement => {
|
||||
.then(imageElement =>
|
||||
{
|
||||
if (id !== requestIdRef.current) return;
|
||||
setCurrentPictureUrl(imageElement.src);
|
||||
})
|
||||
.catch(error => NitroLogger.error('Failed to apply effects to picture', error));
|
||||
}, 50);
|
||||
|
||||
return () => {
|
||||
return () =>
|
||||
{
|
||||
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
|
||||
};
|
||||
}, [ stableTexture, selectedEffects ]);
|
||||
|
||||
@@ -19,22 +19,22 @@ export const CameraWidgetEffectListItemView: FC<CameraWidgetEffectListItemViewPr
|
||||
const { effect = null, thumbnailUrl = null, isActive = false, isLocked = false, selectEffect = null, removeEffect = null } = props;
|
||||
|
||||
return (
|
||||
<LayoutGridItem itemActive={ isActive } title={ LocalizeText(!isLocked ? (`camera.effect.name.${ effect.name }`) : `camera.effect.required.level ${ effect.minLevel }`) } onClick={ event => (!isActive && selectEffect()) }>
|
||||
{ isActive &&
|
||||
<LayoutGridItem itemActive={ isActive } title={ LocalizeText(!isLocked ? (`camera.effect.name.${ effect.name }`) : `camera.effect.required.level ${ effect.minLevel }`) } onClick={ event => (!isActive && selectEffect()) }>
|
||||
{ isActive &&
|
||||
<Button className="rounded-circle remove-effect" variant="danger" onClick={ removeEffect }>
|
||||
<FaTimes className="fa-icon" />
|
||||
</Button> }
|
||||
{ !isLocked && (thumbnailUrl && thumbnailUrl.length > 0) &&
|
||||
{ !isLocked && (thumbnailUrl && thumbnailUrl.length > 0) &&
|
||||
<div className="effect-thumbnail-image border">
|
||||
<img alt="" src={ thumbnailUrl } />
|
||||
</div> }
|
||||
{ isLocked &&
|
||||
{ isLocked &&
|
||||
<Text bold center>
|
||||
<div>
|
||||
<FaLock className="fa-icon" />
|
||||
</div>
|
||||
{ effect.minLevel }
|
||||
</Text> }
|
||||
</LayoutGridItem>
|
||||
</LayoutGridItem>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,8 +25,8 @@ export const CameraWidgetEffectListView: FC<CameraWidgetEffectListViewProps> = p
|
||||
const isActive = (selectedEffects.findIndex(selectedEffect => (selectedEffect.effect.name === effect.name)) > -1);
|
||||
|
||||
// return <CameraWidgetEffectListItemView key={ index } effect={ effect } isActive={ isActive } isLocked={ (effect.minLevel > myLevel) } removeEffect={ () => processAction('remove_effect', effect.name) } selectEffect={ () => processAction('select_effect', effect.name) } thumbnailUrl={ ((thumbnailUrl && thumbnailUrl.thumbnailUrl) || null) } />;
|
||||
|
||||
return <CameraWidgetEffectListItemView key={ index } effect={ effect } thumbnailUrl={ ((thumbnailUrl && thumbnailUrl.thumbnailUrl) || null) } isActive={ isActive } isLocked={ (effect.minLevel > myLevel) } selectEffect={ () => processAction('select_effect', effect.name) } removeEffect={ () => processAction('remove_effect', effect.name) } />
|
||||
|
||||
return <CameraWidgetEffectListItemView key={ index } effect={ effect } thumbnailUrl={ ((thumbnailUrl && thumbnailUrl.thumbnailUrl) || null) } isActive={ isActive } isLocked={ (effect.minLevel > myLevel) } selectEffect={ () => processAction('select_effect', effect.name) } removeEffect={ () => processAction('remove_effect', effect.name) } />;
|
||||
}) }
|
||||
</Grid>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { GetSessionDataManager } from '@nitrots/nitro-renderer';
|
||||
import { FC, useState } from 'react';
|
||||
import { CalendarItemState, ICalendarItem, LocalizeText } from '../../api';
|
||||
import { Button, Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
|
||||
import { useHasPermission } from '../../hooks';
|
||||
import { CalendarItemView } from './CalendarItemView';
|
||||
|
||||
interface CalendarViewProps
|
||||
@@ -23,6 +23,7 @@ export const CalendarView: FC<CalendarViewProps> = props =>
|
||||
const { onClose = null, campaignName = null, currentDay = null, numDays = null, missedDays = null, openedDays = null, openPackage = null, receivedProducts = null } = props;
|
||||
const [ selectedDay, setSelectedDay ] = useState(currentDay);
|
||||
const [ index, setIndex ] = useState(Math.max(0, (selectedDay - 1)));
|
||||
const isModerator = useHasPermission('acc_calendar_force');
|
||||
|
||||
const getDayState = (day: number) =>
|
||||
{
|
||||
@@ -109,7 +110,7 @@ export const CalendarView: FC<CalendarViewProps> = props =>
|
||||
<Text>{ dayMessage(selectedDay) }</Text>
|
||||
</div>
|
||||
<div>
|
||||
{ GetSessionDataManager().isModerator &&
|
||||
{ isModerator &&
|
||||
<Button variant="danger" onClick={ forceOpen }>Force open</Button> }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { CatalogAdminCreateOfferComposer, CatalogAdminCreatePageComposer, CatalogAdminDeleteOfferComposer, CatalogAdminDeletePageComposer, CatalogAdminMoveOfferComposer, CatalogAdminMovePageComposer, CatalogAdminPublishComposer, CatalogAdminResultEvent, CatalogAdminSaveOfferComposer, CatalogAdminSavePageComposer } from '@nitrots/nitro-renderer';
|
||||
import { createContext, FC, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { ICatalogNode, IPurchasableOffer, NotificationAlertType, SendMessageComposer } from '../../api';
|
||||
import { useCatalog, useMessageEvent, useNotification } from '../../hooks';
|
||||
import { useCatalogUiState, useMessageEvent, useNotification } from '../../hooks';
|
||||
|
||||
export interface IPageEditData
|
||||
{
|
||||
pageId?: number;
|
||||
caption: string;
|
||||
captionSave: string;
|
||||
parentId: number;
|
||||
catalogMode: string;
|
||||
pageLayout: string;
|
||||
iconImage: number;
|
||||
enabled: string;
|
||||
visible: string;
|
||||
minRank: number;
|
||||
@@ -76,7 +78,7 @@ export const useCatalogAdmin = () => useContext(CatalogAdminContext);
|
||||
|
||||
export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children }) =>
|
||||
{
|
||||
const { currentType } = useCatalog();
|
||||
const { currentType } = useCatalogUiState();
|
||||
const [ adminMode, setAdminMode ] = useState(false);
|
||||
const [ editingOffer, setEditingOffer ] = useState<IPurchasableOffer | null>(null);
|
||||
const [ editingPageData, setEditingPageData ] = useState(false);
|
||||
@@ -88,7 +90,6 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children })
|
||||
const pendingActionRef = useRef<string | null>(null);
|
||||
const { simpleAlert = null } = useNotification();
|
||||
|
||||
// Keyboard shortcuts: Esc to close edit panels
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!adminMode) return;
|
||||
@@ -97,7 +98,10 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children })
|
||||
{
|
||||
if(e.key === 'Escape')
|
||||
{
|
||||
if(editingOffer) { setEditingOffer(null); e.preventDefault(); return; }
|
||||
if(editingOffer)
|
||||
{
|
||||
setEditingOffer(null); e.preventDefault(); return;
|
||||
}
|
||||
if(editingPageData || editingRootPage || editingPageNode)
|
||||
{
|
||||
setEditingPageData(false);
|
||||
@@ -173,11 +177,13 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children })
|
||||
setLoading(true);
|
||||
setLastError(null);
|
||||
pendingActionRef.current = 'savePage';
|
||||
|
||||
SendMessageComposer(new CatalogAdminSavePageComposer(
|
||||
data.pageId || 0, data.caption, data.caption, data.pageLayout, 0,
|
||||
data.pageId || 0, data.caption, data.captionSave, data.pageLayout, data.iconImage,
|
||||
data.minRank, data.visible === '1', data.enabled === '1',
|
||||
data.orderNum, data.parentId,
|
||||
data.pageHeadline || '', data.pageTeaser || '', data.pageTextDetails || '', currentType, data.catalogMode
|
||||
data.pageHeadline || '', data.pageTeaser || '', data.pageTextDetails || '', currentType, data.catalogMode,
|
||||
data.pageText1 || ''
|
||||
));
|
||||
}, [ currentType ]);
|
||||
|
||||
@@ -187,7 +193,7 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children })
|
||||
setLastError(null);
|
||||
pendingActionRef.current = 'createPage';
|
||||
SendMessageComposer(new CatalogAdminCreatePageComposer(
|
||||
data.caption, data.caption, data.pageLayout, 0,
|
||||
data.caption, data.captionSave, data.pageLayout, data.iconImage,
|
||||
data.minRank, data.visible === '1', data.enabled === '1',
|
||||
data.orderNum, data.parentId, currentType, data.catalogMode
|
||||
));
|
||||
@@ -280,7 +286,7 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children })
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CatalogAdminContext.Provider value={ {
|
||||
<CatalogAdminContext value={ {
|
||||
adminMode, setAdminMode,
|
||||
editingOffer, setEditingOffer,
|
||||
editingPageData, setEditingPageData,
|
||||
@@ -293,6 +299,6 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children })
|
||||
publishCatalog
|
||||
} }>
|
||||
{ children }
|
||||
</CatalogAdminContext.Provider>
|
||||
</CatalogAdminContext>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { AddLinkEventTracker, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect } from 'react';
|
||||
import { FaCog, FaEdit, FaEye, FaEyeSlash, FaPlus, FaTrash } from 'react-icons/fa';
|
||||
import { CatalogType, LocalizeText } from '../../api';
|
||||
import { NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common';
|
||||
import { useCatalog } from '../../hooks';
|
||||
import { CatalogType, GetConfigurationValue, LocalizeText } from '../../api';
|
||||
import { Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common';
|
||||
import { useCatalogActions, useCatalogData, useCatalogUiState, useHasPermission } from '../../hooks';
|
||||
import { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext';
|
||||
import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView';
|
||||
import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView';
|
||||
@@ -18,14 +18,19 @@ import { MarketplacePostOfferView } from './views/page/layout/marketplace/Market
|
||||
|
||||
const CatalogClassicViewInner: FC<{}> = () =>
|
||||
{
|
||||
const { isVisible = false, setIsVisible = null, rootNode = null, currentPage = null, navigationHidden = false, setNavigationHidden = null, activeNodes = [], searchResult = null, setSearchResult = null, openPageByName = null, openPageByOfferId = null, activateNode = null, openCatalogByType = null, toggleCatalogByType = null, currentType = CatalogType.NORMAL } = useCatalog();
|
||||
const { rootNode = null, currentPage = null, searchResult = null } = useCatalogData();
|
||||
const { isVisible = false, setIsVisible = null, navigationHidden = false, setNavigationHidden = null, activeNodes = [], setSearchResult = null, currentType = CatalogType.NORMAL } = useCatalogUiState();
|
||||
const { openPageByName = null, openPageByOfferId = null, activateNode = null, openCatalogByType = null, toggleCatalogByType = null } = useCatalogActions();
|
||||
const catalogAdmin = useCatalogAdmin();
|
||||
const adminMode = catalogAdmin?.adminMode ?? false;
|
||||
const setAdminMode = catalogAdmin?.setAdminMode ?? (() => {});
|
||||
const setAdminMode = catalogAdmin?.setAdminMode ?? (() =>
|
||||
{});
|
||||
const hasPendingChanges = catalogAdmin?.hasPendingChanges ?? false;
|
||||
const publishCatalog = catalogAdmin?.publishCatalog ?? (() => {});
|
||||
const publishCatalog = catalogAdmin?.publishCatalog ?? (() =>
|
||||
{});
|
||||
const loading = catalogAdmin?.loading ?? false;
|
||||
const isMod = GetSessionDataManager().isModerator;
|
||||
|
||||
const isMod = useHasPermission('acc_catalogfurni');
|
||||
const buildersClubHeaderStyle = (currentType === CatalogType.BUILDER)
|
||||
? { borderColor: '#d79d2e', borderBottomColor: '#000', background: 'linear-gradient(180deg, #d89f2d 0%, #c68515 100%)' }
|
||||
: undefined;
|
||||
@@ -148,13 +153,19 @@ const CatalogClassicViewInner: FC<{}> = () =>
|
||||
{ adminMode &&
|
||||
<div className="flex items-center gap-0.5 ml-1" onClick={ e => e.stopPropagation() }>
|
||||
<FaEdit className="text-[8px] text-primary cursor-pointer hover:text-dark" title={ LocalizeText('catalog.admin.edit.title') }
|
||||
onClick={ () => { catalogAdmin.setEditingPageNode(child); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true); } } />
|
||||
onClick={ () =>
|
||||
{
|
||||
catalogAdmin.setEditingPageNode(child); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true);
|
||||
} } />
|
||||
<span className="cursor-pointer" title={ isHidden ? LocalizeText('catalog.admin.show') : LocalizeText('catalog.admin.hide') }
|
||||
onClick={ () => catalogAdmin.togglePageVisible(child.pageId) }>
|
||||
{ isHidden ? <FaEye className="text-[8px] text-success" /> : <FaEyeSlash className="text-[8px] text-muted" /> }
|
||||
</span>
|
||||
<FaTrash className="text-[8px] text-danger cursor-pointer hover:text-red-800" title={ LocalizeText('catalog.admin.delete.title') }
|
||||
onClick={ () => { if(confirm(LocalizeText('catalog.admin.delete.category.confirm', [ 'name' ], [ child.localization ]))) catalogAdmin.deletePage(child.pageId); } } />
|
||||
onClick={ () =>
|
||||
{
|
||||
if(confirm(LocalizeText('catalog.admin.delete.category.confirm', [ 'name' ], [ child.localization ]))) catalogAdmin.deletePage(child.pageId);
|
||||
} } />
|
||||
</div> }
|
||||
</div>
|
||||
</NitroCardTabsItemView>
|
||||
@@ -171,14 +182,17 @@ const CatalogClassicViewInner: FC<{}> = () =>
|
||||
<div className="flex items-center gap-2 mb-1 nitro-catalog-classic-admin-actions">
|
||||
<button
|
||||
className="flex items-center gap-1 text-[9px] text-success hover:text-green-800 cursor-pointer transition-colors"
|
||||
onClick={ () => catalogAdmin.createPage({ caption: 'New Category', catalogMode: currentType, pageLayout: 'default_3x3', minRank: 1, visible: '1', enabled: '1', orderNum: 99, parentId: rootNode.pageId }) }
|
||||
onClick={ () => catalogAdmin.createPage({ caption: 'New Category', captionSave: 'New Category', catalogMode: currentType, pageLayout: 'default_3x3', iconImage: 0, minRank: 1, visible: '1', enabled: '1', orderNum: 99, parentId: rootNode.pageId }) }
|
||||
>
|
||||
<FaPlus className="text-[8px]" />
|
||||
<span>{ LocalizeText('catalog.admin.new') }</span>
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1 text-[9px] text-primary hover:text-dark cursor-pointer transition-colors"
|
||||
onClick={ () => { catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(true); catalogAdmin.setEditingPageData(true); } }
|
||||
onClick={ () =>
|
||||
{
|
||||
catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(true); catalogAdmin.setEditingPageData(true);
|
||||
} }
|
||||
>
|
||||
<FaEdit className="text-[8px]" />
|
||||
<span>{ LocalizeText('catalog.admin.root') }</span>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { AddLinkEventTracker, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { FaCog, FaEdit, FaEye, FaEyeSlash, FaHeart, FaPlus, FaStar, FaTrash } from 'react-icons/fa';
|
||||
import { CatalogType, LocalizeText } from '../../api';
|
||||
import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common';
|
||||
import { useCatalog, useCatalogFavorites } from '../../hooks';
|
||||
import { useCatalogActions, useCatalogData, useCatalogFavorites, useCatalogUiState, useHasPermission } from '../../hooks';
|
||||
import { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext';
|
||||
import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView';
|
||||
import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView';
|
||||
@@ -18,17 +18,21 @@ import { MarketplacePostOfferView } from './views/page/layout/marketplace/Market
|
||||
|
||||
const CatalogModernViewInner: FC<{}> = () =>
|
||||
{
|
||||
const { isVisible = false, setIsVisible = null, rootNode = null, currentPage = null, navigationHidden = false, setNavigationHidden = null, activeNodes = [], searchResult = null, setSearchResult = null, openPageByName = null, openPageByOfferId = null, activateNode = null, openCatalogByType = null, toggleCatalogByType = null, currentType = CatalogType.NORMAL } = useCatalog();
|
||||
const { rootNode = null, currentPage = null, searchResult = null } = useCatalogData();
|
||||
const { isVisible = false, setIsVisible = null, navigationHidden = false, setNavigationHidden = null, activeNodes = [], setSearchResult = null, currentType = CatalogType.NORMAL } = useCatalogUiState();
|
||||
const { openPageByName = null, openPageByOfferId = null, activateNode = null, openCatalogByType = null, toggleCatalogByType = null } = useCatalogActions();
|
||||
const catalogAdmin = useCatalogAdmin();
|
||||
const adminMode = catalogAdmin?.adminMode ?? false;
|
||||
const setAdminMode = catalogAdmin?.setAdminMode ?? (() => {});
|
||||
const setAdminMode = catalogAdmin?.setAdminMode ?? (() =>
|
||||
{});
|
||||
const hasPendingChanges = catalogAdmin?.hasPendingChanges ?? false;
|
||||
const publishCatalog = catalogAdmin?.publishCatalog ?? (() => {});
|
||||
const publishCatalog = catalogAdmin?.publishCatalog ?? (() =>
|
||||
{});
|
||||
const loading = catalogAdmin?.loading ?? false;
|
||||
const { favoriteOfferIds, favoritePageIds } = useCatalogFavorites();
|
||||
const [ showFavorites, setShowFavorites ] = useState(false);
|
||||
|
||||
const isMod = GetSessionDataManager().isModerator;
|
||||
const isMod = useHasPermission('acc_catalogfurni');
|
||||
const totalFavs = favoriteOfferIds.length + favoritePageIds.length;
|
||||
const buildersClubHeaderStyle = (currentType === CatalogType.BUILDER)
|
||||
? { borderColor: '#d79d2e', borderBottomColor: '#000', background: 'linear-gradient(180deg, #d89f2d 0%, #c68515 100%)' }
|
||||
@@ -162,7 +166,7 @@ const CatalogModernViewInner: FC<{}> = () =>
|
||||
<button
|
||||
className="flex items-center gap-1 text-[9px] text-success hover:text-green-800 cursor-pointer transition-colors"
|
||||
title={ LocalizeText('catalog.admin.new.root.category') }
|
||||
onClick={ () => catalogAdmin.createPage({ caption: 'New Category', catalogMode: currentType, pageLayout: 'default_3x3', minRank: 1, visible: '1', enabled: '1', orderNum: 99, parentId: rootNode.pageId }) }
|
||||
onClick={ () => catalogAdmin.createPage({ caption: 'New Category', captionSave: 'New Category', catalogMode: currentType, pageLayout: 'default_3x3', iconImage: 0, minRank: 1, visible: '1', enabled: '1', orderNum: 99, parentId: rootNode.pageId }) }
|
||||
>
|
||||
<FaPlus className="text-[8px]" />
|
||||
<span className="whitespace-nowrap">{ LocalizeText('catalog.admin.new') }</span>
|
||||
@@ -170,7 +174,10 @@ const CatalogModernViewInner: FC<{}> = () =>
|
||||
<button
|
||||
className="flex items-center gap-1 text-[9px] text-primary hover:text-dark cursor-pointer transition-colors"
|
||||
title={ LocalizeText('catalog.admin.edit.root') }
|
||||
onClick={ () => { catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(true); catalogAdmin.setEditingPageData(true); } }
|
||||
onClick={ () =>
|
||||
{
|
||||
catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(true); catalogAdmin.setEditingPageData(true);
|
||||
} }
|
||||
>
|
||||
<FaEdit className="text-[8px]" />
|
||||
<span className="whitespace-nowrap">{ LocalizeText('catalog.admin.root') }</span>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { FC } from 'react';
|
||||
import { GetConfigurationValue } from '../../api';
|
||||
import { useCatalog } from '../../hooks';
|
||||
import { useCatalogData } from '../../hooks';
|
||||
import { CatalogClassicView } from './CatalogClassicView';
|
||||
import { CatalogModernView } from './CatalogModernView';
|
||||
|
||||
export const CatalogView: FC<{}> = () =>
|
||||
{
|
||||
const { catalogLocalizationVersion = 0 } = useCatalog();
|
||||
const { catalogLocalizationVersion = 0 } = useCatalogData();
|
||||
const useNewStyle = GetConfigurationValue<boolean>('catalog.style.new', false);
|
||||
|
||||
if(useNewStyle) return (
|
||||
|
||||
@@ -2,12 +2,12 @@ import { FC, useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { FaSave, FaSpinner, FaTimes, FaTrash } from 'react-icons/fa';
|
||||
import { LocalizeText } from '../../../../api';
|
||||
import { useCatalog } from '../../../../hooks';
|
||||
import { useCatalogData } from '../../../../hooks';
|
||||
import { IOfferEditData, useCatalogAdmin } from '../../CatalogAdminContext';
|
||||
|
||||
export const CatalogAdminOfferEditView: FC<{}> = () =>
|
||||
{
|
||||
const { currentPage = null } = useCatalog();
|
||||
const { currentPage = null } = useCatalogData();
|
||||
const catalogAdmin = useCatalogAdmin();
|
||||
const editingOffer = catalogAdmin?.editingOffer ?? null;
|
||||
const setEditingOffer = catalogAdmin?.setEditingOffer;
|
||||
@@ -91,9 +91,10 @@ export const CatalogAdminOfferEditView: FC<{}> = () =>
|
||||
orderNumber
|
||||
};
|
||||
|
||||
const success = isNew ? await createOffer(data) : await saveOffer(data);
|
||||
if(isNew) createOffer(data);
|
||||
else saveOffer(data);
|
||||
|
||||
if(success && setEditingOffer) setEditingOffer(null);
|
||||
if(setEditingOffer) setEditingOffer(null);
|
||||
};
|
||||
|
||||
const handleDelete = () =>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { FaSave, FaSpinner, FaTimes, FaTrash } from 'react-icons/fa';
|
||||
import { FaLanguage, FaSave, FaSpinner, FaTimes, FaTrash } from 'react-icons/fa';
|
||||
import { CatalogType, LocalizeText } from '../../../../api';
|
||||
import { useCatalog } from '../../../../hooks';
|
||||
import { useCatalogData, useCatalogUiState, useTranslationActions, useTranslationState } from '../../../../hooks';
|
||||
import { IPageEditData, useCatalogAdmin } from '../../CatalogAdminContext';
|
||||
|
||||
const LAYOUT_OPTIONS = [
|
||||
@@ -15,13 +15,15 @@ const LAYOUT_OPTIONS = [
|
||||
];
|
||||
|
||||
const MODE_OPTIONS = [
|
||||
{ value: CatalogType.NORMAL, label: 'Normale' },
|
||||
{ value: 'BOTH', label: 'Entrambi' }
|
||||
{ value: 'NORMAL', label: 'Normal' },
|
||||
{ value: 'BUILDER', label: 'Builder' },
|
||||
{ value: 'BOTH', label: 'Both' }
|
||||
];
|
||||
|
||||
export const CatalogAdminPageEditView: FC<{}> = () =>
|
||||
{
|
||||
const { currentPage = null, activeNodes = [], rootNode = null, currentType = CatalogType.NORMAL } = useCatalog();
|
||||
const { currentPage = null, rootNode = null } = useCatalogData();
|
||||
const { activeNodes = [], currentType = CatalogType.NORMAL } = useCatalogUiState();
|
||||
const catalogAdmin = useCatalogAdmin();
|
||||
const editingPageData = catalogAdmin?.editingPageData ?? false;
|
||||
const editingRootPage = catalogAdmin?.editingRootPage ?? false;
|
||||
@@ -29,17 +31,22 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
|
||||
const loading = catalogAdmin?.loading ?? false;
|
||||
|
||||
const [ caption, setCaption ] = useState('');
|
||||
const [ catalogMode, setCatalogMode ] = useState(CatalogType.NORMAL);
|
||||
const [ captionSave, setCaptionSave ] = useState('');
|
||||
const [ catalogMode, setCatalogMode ] = useState<string>('NORMAL');
|
||||
const [ pageLayout, setPageLayout ] = useState('default_3x3');
|
||||
const [ iconImage, setIconImage ] = useState(0);
|
||||
const [ minRank, setMinRank ] = useState(1);
|
||||
const [ visible, setVisible ] = useState('1');
|
||||
const [ enabled, setEnabled ] = useState('1');
|
||||
const [ orderNum, setOrderNum ] = useState(0);
|
||||
|
||||
// Resolve what we're editing:
|
||||
// 1. editingPageNode (explicit node from sidebar click)
|
||||
// 2. editingRootPage (root button)
|
||||
// 3. current active page (from "Modifica Pagina" in layout)
|
||||
const [ parentId, setParentId ] = useState(-1);
|
||||
const [ pageText1, setPageText1 ] = useState('');
|
||||
const [ showTranslate, setShowTranslate ] = useState(false);
|
||||
const [ translateTargetLanguage, setTranslateTargetLanguage ] = useState('en');
|
||||
const [ isTranslating, setIsTranslating ] = useState(false);
|
||||
const [ translateError, setTranslateError ] = useState<string | null>(null);
|
||||
const { supportedLanguages = [], languagesLoading = false } = useTranslationState();
|
||||
const { translateText, ensureSupportedLanguagesLoaded } = useTranslationActions();
|
||||
const targetNode = editingPageNode
|
||||
? editingPageNode
|
||||
: editingRootPage
|
||||
@@ -61,12 +68,26 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
|
||||
if(!editingPageData || !targetNode) return;
|
||||
|
||||
setCaption(targetNode.localization || '');
|
||||
setCatalogMode(currentType === CatalogType.BUILDER ? CatalogType.BUILDER : (currentType || CatalogType.NORMAL));
|
||||
setCaptionSave(targetNode.pageName || targetNode.localization || '');
|
||||
setCatalogMode(currentType === CatalogType.BUILDER ? 'BUILDER' : 'NORMAL');
|
||||
setPageLayout(currentPage?.layoutCode || 'default_3x3');
|
||||
setIconImage(targetNode.iconId ?? 0);
|
||||
setVisible(targetNode.isVisible ? '1' : '0');
|
||||
setEnabled('1');
|
||||
setMinRank(1);
|
||||
setOrderNum(0);
|
||||
const matchesLoadedPage = currentPage && targetPageId === currentPage.pageId;
|
||||
const existingText1 = matchesLoadedPage && currentPage.localization
|
||||
? currentPage.localization.getText(0)
|
||||
: '';
|
||||
setPageText1(existingText1 || '');
|
||||
setShowTranslate(false);
|
||||
setIsTranslating(false);
|
||||
setTranslateError(null);
|
||||
const wireParentId = targetNode.parentId;
|
||||
setParentId(typeof wireParentId === 'number' && wireParentId !== -1
|
||||
? wireParentId
|
||||
: (targetNode.parent ? targetNode.parent.pageId : -1));
|
||||
}, [ editingPageData, targetNode, currentPage, currentType ]);
|
||||
|
||||
if(!editingPageData || !targetNode) return null;
|
||||
@@ -77,23 +98,65 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
|
||||
{
|
||||
if(!catalogAdmin?.savePage) return;
|
||||
|
||||
const parentNode = targetNode.parent;
|
||||
|
||||
const data: IPageEditData = {
|
||||
pageId: targetPageId,
|
||||
caption,
|
||||
captionSave,
|
||||
catalogMode,
|
||||
pageLayout,
|
||||
iconImage,
|
||||
minRank,
|
||||
visible,
|
||||
enabled,
|
||||
orderNum,
|
||||
parentId: parentNode ? parentNode.pageId : -1,
|
||||
parentId,
|
||||
pageText1,
|
||||
};
|
||||
|
||||
const success = await catalogAdmin.savePage(data);
|
||||
catalogAdmin.savePage(data);
|
||||
|
||||
if(success) closeForm();
|
||||
closeForm();
|
||||
};
|
||||
|
||||
const openTranslate = () =>
|
||||
{
|
||||
const next = !showTranslate;
|
||||
setShowTranslate(next);
|
||||
setTranslateError(null);
|
||||
if(next) ensureSupportedLanguagesLoaded();
|
||||
};
|
||||
|
||||
const runTranslate = async () =>
|
||||
{
|
||||
if(!pageText1.trim().length)
|
||||
{
|
||||
setTranslateError('Nothing to translate yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
if(!translateTargetLanguage)
|
||||
{
|
||||
setTranslateError('Pick a language first.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTranslating(true);
|
||||
setTranslateError(null);
|
||||
|
||||
try
|
||||
{
|
||||
const result = await translateText(pageText1, translateTargetLanguage);
|
||||
setPageText1(result?.translatedText || pageText1);
|
||||
setShowTranslate(false);
|
||||
}
|
||||
catch(error)
|
||||
{
|
||||
setTranslateError((error as Error)?.message || 'Translation failed.');
|
||||
}
|
||||
finally
|
||||
{
|
||||
setIsTranslating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () =>
|
||||
@@ -101,16 +164,16 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
|
||||
if(!catalogAdmin?.deletePage || isRoot) return;
|
||||
if(!confirm(LocalizeText('catalog.admin.delete.page.confirm', [ 'name' ], [ targetNode.localization ]))) return;
|
||||
|
||||
const success = await catalogAdmin.deletePage(targetPageId);
|
||||
catalogAdmin.deletePage(targetPageId);
|
||||
|
||||
if(success) closeForm();
|
||||
closeForm();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded border-2 border-card-grid-item-border p-2.5 mb-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[11px] font-bold text-primary uppercase tracking-wide">
|
||||
{ isRoot ? LocalizeText('catalog.admin.edit.root') : `${ LocalizeText('catalog.admin.edit') } ${ targetNode.localization } (#${ targetPageId })` }
|
||||
{ isRoot ? LocalizeText('catalog.admin.edit.root') : `${ LocalizeText('catalog.admin.edit') } ${ targetNode.localization }` }
|
||||
</span>
|
||||
<FaTimes className="text-muted cursor-pointer hover:text-danger text-[10px]" onClick={ closeForm } />
|
||||
</div>
|
||||
@@ -124,13 +187,19 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
|
||||
<label className="text-[9px] text-muted uppercase font-bold">Min Rank</label>
|
||||
<input className={ inputClass } min={ 1 } type="number" value={ minRank } onChange={ e => setMinRank(parseInt(e.target.value) || 1) } />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 col-span-2">
|
||||
<label className="text-[9px] text-muted uppercase font-bold">Caption Save (Localisation Key)</label>
|
||||
<input className={ inputClass } value={ captionSave } onChange={ e => setCaptionSave(e.target.value) } />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<label className="text-[9px] text-muted uppercase font-bold">Icon Image</label>
|
||||
<input className={ inputClass } min={ 0 } type="number" value={ iconImage } onChange={ e => setIconImage(parseInt(e.target.value) || 0) } />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<label className="text-[9px] text-muted uppercase font-bold">Mode</label>
|
||||
{ currentType === CatalogType.BUILDER
|
||||
? <div className={ `${ inputClass } flex items-center min-h-[28px] bg-gray-100 text-muted` }>Builders Club</div>
|
||||
: <select className={ inputClass } value={ catalogMode } onChange={ e => setCatalogMode(e.target.value) }>
|
||||
{ MODE_OPTIONS.map(option => <option key={ option.value } value={ option.value }>{ option.label }</option>) }
|
||||
</select> }
|
||||
<select className={ inputClass } value={ catalogMode } onChange={ e => setCatalogMode(e.target.value) }>
|
||||
{ MODE_OPTIONS.map(option => <option key={ option.value } value={ option.value }>{ option.label }</option>) }
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<label className="text-[9px] text-muted uppercase font-bold">Layout</label>
|
||||
@@ -142,6 +211,10 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
|
||||
<label className="text-[9px] text-muted uppercase font-bold">{ LocalizeText('catalog.admin.order') }</label>
|
||||
<input className={ inputClass } min={ 0 } type="number" value={ orderNum } onChange={ e => setOrderNum(parseInt(e.target.value) || 0) } />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<label className="text-[9px] text-muted uppercase font-bold">Parent ID</label>
|
||||
<input className={ inputClass } disabled={ isRoot } type="number" value={ parentId } onChange={ e => setParentId(parseInt(e.target.value) || -1) } />
|
||||
</div>
|
||||
<div className="flex items-end gap-2 pb-0.5">
|
||||
<label className="flex items-center gap-1 text-[10px] cursor-pointer">
|
||||
<input className="accent-primary" checked={ visible === '1' } type="checkbox" onChange={ e => setVisible(e.target.checked ? '1' : '0') } />
|
||||
@@ -152,6 +225,50 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
|
||||
{ LocalizeText('catalog.admin.enabled') }
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 col-span-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[9px] text-muted uppercase font-bold">Page Text 1 <span className="text-muted normal-case font-normal opacity-70">(leave blank to keep current)</span></label>
|
||||
<button
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-bold text-primary border border-primary/40 hover:bg-primary/10 transition-colors cursor-pointer disabled:opacity-50"
|
||||
disabled={ isTranslating || !pageText1.trim().length }
|
||||
title="Translate via Google Translate"
|
||||
type="button"
|
||||
onClick={ openTranslate }>
|
||||
{ isTranslating ? <FaSpinner className="text-[8px] animate-spin" /> : <FaLanguage className="text-[10px]" /> }
|
||||
Translate
|
||||
</button>
|
||||
</div>
|
||||
{ showTranslate &&
|
||||
<div className="flex items-center gap-1 mb-1 p-1 bg-gray-50 border border-card-grid-item-border rounded">
|
||||
<select
|
||||
className={ `${ inputClass } flex-1` }
|
||||
disabled={ isTranslating || languagesLoading }
|
||||
value={ translateTargetLanguage }
|
||||
onChange={ e => setTranslateTargetLanguage(e.target.value) }>
|
||||
{ languagesLoading && !supportedLanguages.length &&
|
||||
<option value="">Loading languages…</option> }
|
||||
{ supportedLanguages.map(lang => (
|
||||
<option key={ lang.code } value={ lang.code }>{ lang.name } ({ lang.code })</option>
|
||||
)) }
|
||||
</select>
|
||||
<button
|
||||
className="px-2 py-1 rounded text-[10px] font-bold bg-primary text-white hover:bg-secondary transition-colors cursor-pointer disabled:opacity-50"
|
||||
disabled={ isTranslating || !translateTargetLanguage || !pageText1.trim().length }
|
||||
type="button"
|
||||
onClick={ runTranslate }>
|
||||
{ isTranslating ? <FaSpinner className="text-[8px] animate-spin" /> : 'Apply' }
|
||||
</button>
|
||||
<button
|
||||
className="px-2 py-1 rounded text-[10px] font-bold text-muted border border-card-grid-item-border hover:bg-gray-100 transition-colors cursor-pointer"
|
||||
disabled={ isTranslating }
|
||||
type="button"
|
||||
onClick={ () => { setShowTranslate(false); setTranslateError(null); } }>
|
||||
Cancel
|
||||
</button>
|
||||
</div> }
|
||||
{ translateError && <span className="text-[9px] text-danger">{ translateError }</span> }
|
||||
<textarea className={ `${ inputClass } min-h-[60px] resize-y` } value={ pageText1 } onChange={ e => setPageText1(e.target.value) } />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-2">
|
||||
|
||||
@@ -2,11 +2,12 @@ import { GetTickerTime } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useMemo, useState } from 'react';
|
||||
import { CatalogType, FriendlyTime, LocalizeText } from '../../../../api';
|
||||
import buildersClubIcon from '../../../../assets/images/toolbar/icons/buildersclub.png';
|
||||
import { useCatalog } from '../../../../hooks';
|
||||
import { useCatalogData, useCatalogUiState } from '../../../../hooks';
|
||||
|
||||
export const CatalogBuildersClubStatusView: FC = () =>
|
||||
{
|
||||
const { currentType = CatalogType.NORMAL, furniCount = 0, furniLimit = 0, secondsLeft = 0, secondsLeftWithGrace = 0, updateTime = 0 } = useCatalog();
|
||||
const { furniCount = 0, furniLimit = 0, secondsLeft = 0, secondsLeftWithGrace = 0, updateTime = 0 } = useCatalogData();
|
||||
const { currentType = CatalogType.NORMAL } = useCatalogUiState();
|
||||
const [ ticker, setTicker ] = useState(() => GetTickerTime());
|
||||
|
||||
useEffect(() =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { FC } from 'react';
|
||||
import { GetConfigurationValue } from '../../../../api';
|
||||
|
||||
export interface CatalogHeaderViewProps
|
||||
@@ -9,12 +9,7 @@ export interface CatalogHeaderViewProps
|
||||
export const CatalogHeaderView: FC<CatalogHeaderViewProps> = props =>
|
||||
{
|
||||
const { imageUrl = null } = props;
|
||||
const [ displayImageUrl, setDisplayImageUrl ] = useState('');
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
setDisplayImageUrl(imageUrl ?? GetConfigurationValue<string>('catalog.asset.image.url').replace('%name%', 'catalog_header_roombuilder'));
|
||||
}, [ imageUrl ]);
|
||||
const displayImageUrl = imageUrl ?? GetConfigurationValue<string>('catalog.asset.image.url').replace('%name%', 'catalog_header_roombuilder');
|
||||
|
||||
return <div className="flex justify-center items-center w-full nitro-catalog-header">
|
||||
<img src={ displayImageUrl } onError={ ({ currentTarget }) =>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FC, useMemo } from 'react';
|
||||
import { FaHeart, FaStar, FaTimes } from 'react-icons/fa';
|
||||
import { ICatalogNode, LocalizeText } from '../../../../api';
|
||||
import { useCatalog, useCatalogFavorites } from '../../../../hooks';
|
||||
import { useCatalogActions, useCatalogData, useCatalogFavorites } from '../../../../hooks';
|
||||
import { CatalogIconView } from '../catalog-icon/CatalogIconView';
|
||||
|
||||
interface CatalogFavoritesViewProps
|
||||
@@ -13,7 +13,8 @@ export const CatalogFavoritesView: FC<CatalogFavoritesViewProps> = props =>
|
||||
{
|
||||
const { onClose } = props;
|
||||
const { favoriteOffers, favoritePageIds, toggleFavoritePage, toggleFavoriteOffer } = useCatalogFavorites();
|
||||
const { offersToNodes, activateNode, openPageByOfferId, rootNode } = useCatalog();
|
||||
const { offersToNodes, rootNode } = useCatalogData();
|
||||
const { activateNode, openPageByOfferId } = useCatalogActions();
|
||||
|
||||
const favoritePages = useMemo(() =>
|
||||
{
|
||||
@@ -93,13 +94,19 @@ export const CatalogFavoritesView: FC<CatalogFavoritesViewProps> = props =>
|
||||
<div
|
||||
key={ page.pageId }
|
||||
className="group/fav flex items-center gap-2 px-1.5 py-1 bg-card-grid-item rounded border border-card-grid-item-border hover:bg-card-grid-item-active cursor-pointer transition-all duration-100"
|
||||
onClick={ () => { activateNode(page.node); onClose(); } }
|
||||
onClick={ () =>
|
||||
{
|
||||
activateNode(page.node); onClose();
|
||||
} }
|
||||
>
|
||||
<CatalogIconView icon={ page.iconId } />
|
||||
<span className="text-[11px] flex-1 truncate font-medium">{ page.name }</span>
|
||||
<FaTimes
|
||||
className="text-[7px] text-muted opacity-0 group-hover/fav:opacity-100 hover:text-danger transition-all cursor-pointer"
|
||||
onClick={ e => { e.stopPropagation(); toggleFavoritePage(page.pageId); } }
|
||||
onClick={ e =>
|
||||
{
|
||||
e.stopPropagation(); toggleFavoritePage(page.pageId);
|
||||
} }
|
||||
/>
|
||||
</div>
|
||||
)) }
|
||||
@@ -118,7 +125,10 @@ export const CatalogFavoritesView: FC<CatalogFavoritesViewProps> = props =>
|
||||
<div
|
||||
key={ fav.offerId }
|
||||
className="group/fav flex items-center gap-2 px-1.5 py-1 bg-card-grid-item rounded border border-card-grid-item-border hover:bg-card-grid-item-active cursor-pointer transition-all duration-100"
|
||||
onClick={ () => { openPageByOfferId(fav.offerId); onClose(); } }
|
||||
onClick={ () =>
|
||||
{
|
||||
openPageByOfferId(fav.offerId); onClose();
|
||||
} }
|
||||
>
|
||||
{ /* Furni icon */ }
|
||||
<div className="w-7 h-7 flex items-center justify-center shrink-0 bg-white rounded border border-card-grid-item-border overflow-hidden">
|
||||
@@ -132,7 +142,10 @@ export const CatalogFavoritesView: FC<CatalogFavoritesViewProps> = props =>
|
||||
<span className="text-[11px] flex-1 truncate font-medium">{ fav.displayName }</span>
|
||||
<FaTimes
|
||||
className="text-[7px] text-muted opacity-0 group-hover/fav:opacity-100 hover:text-danger transition-all cursor-pointer"
|
||||
onClick={ e => { e.stopPropagation(); toggleFavoriteOffer(fav.offerId); } }
|
||||
onClick={ e =>
|
||||
{
|
||||
e.stopPropagation(); toggleFavoriteOffer(fav.offerId);
|
||||
} }
|
||||
/>
|
||||
</div>
|
||||
)) }
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
|
||||
import { ColorUtils, LocalizeText, MessengerFriend, ProductTypeEnum, SendMessageComposer } from '../../../../api';
|
||||
import { Button, Column, Flex, FormGroup, LayoutCurrencyIcon, LayoutFurniImageView, LayoutGiftTagView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
|
||||
import { CatalogEvent, CatalogInitGiftEvent, CatalogPurchasedEvent } from '../../../../events';
|
||||
import { useCatalog, useFriends, useMessageEvent, useUiEvent } from '../../../../hooks';
|
||||
import { useFriends, useGiftConfiguration, useMessageEvent, useUiEvent } from '../../../../hooks';
|
||||
import { classNames } from '../../../../layout';
|
||||
|
||||
let isBuyingGift = false;
|
||||
@@ -25,9 +25,8 @@ export const CatalogGiftView: FC<{}> = props =>
|
||||
const [ maxBoxIndex, setMaxBoxIndex ] = useState<number>(0);
|
||||
const [ maxRibbonIndex, setMaxRibbonIndex ] = useState<number>(0);
|
||||
const [ receiverNotFound, setReceiverNotFound ] = useState<boolean>(false);
|
||||
const { catalogOptions = null } = useCatalog();
|
||||
const { friends } = useFriends();
|
||||
const { giftConfiguration = null } = catalogOptions;
|
||||
const { data: giftConfiguration = null } = useGiftConfiguration();
|
||||
const [ boxTypes, setBoxTypes ] = useState<number[]>([]);
|
||||
const [ suggestions, setSuggestions ] = useState([]);
|
||||
const [ isAutocompleteVisible, setIsAutocompleteVisible ] = useState(true);
|
||||
@@ -133,7 +132,10 @@ export const CatalogGiftView: FC<{}> = props =>
|
||||
if(isBuyingGift) return;
|
||||
|
||||
isBuyingGift = true;
|
||||
setTimeout(() => { isBuyingGift = false; }, 10000);
|
||||
setTimeout(() =>
|
||||
{
|
||||
isBuyingGift = false;
|
||||
}, 10000);
|
||||
|
||||
SendMessageComposer(new PurchaseFromCatalogAsGiftComposer(pageId, offerId, extraData, receiverName, message, colourId, selectedBoxIndex, selectedRibbonIndex, showMyFace));
|
||||
return;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { FC } from 'react';
|
||||
import { LocalizeText } from '../../../../api';
|
||||
import { useCatalog } from '../../../../hooks';
|
||||
import { useCatalogActions, useCatalogUiState } from '../../../../hooks';
|
||||
|
||||
export const CatalogBreadcrumbView: FC<{}> = () =>
|
||||
{
|
||||
const { activeNodes = [], activateNode } = useCatalog();
|
||||
const { activeNodes = [] } = useCatalogUiState();
|
||||
const { activateNode } = useCatalogActions();
|
||||
|
||||
if(!activeNodes || activeNodes.length === 0)
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FC, useCallback, useRef, useState } from 'react';
|
||||
import { FaArrowsAlt, FaCaretDown, FaCaretUp, FaPlus, FaStar, FaTrash } from 'react-icons/fa';
|
||||
import { CatalogType, ICatalogNode, LocalizeText } from '../../../../api';
|
||||
import { useCatalog, useCatalogFavorites } from '../../../../hooks';
|
||||
import { useCatalogActions, useCatalogFavorites, useCatalogUiState } from '../../../../hooks';
|
||||
import { useCatalogAdmin } from '../../CatalogAdminContext';
|
||||
import { CatalogIconView } from '../catalog-icon/CatalogIconView';
|
||||
import { CatalogNavigationSetView } from './CatalogNavigationSetView';
|
||||
@@ -15,7 +15,8 @@ export interface CatalogNavigationItemViewProps
|
||||
export const CatalogNavigationItemView: FC<CatalogNavigationItemViewProps> = props =>
|
||||
{
|
||||
const { node = null, child = false } = props;
|
||||
const { activateNode = null, currentType = CatalogType.NORMAL } = useCatalog();
|
||||
const { activateNode = null } = useCatalogActions();
|
||||
const { currentType = CatalogType.NORMAL } = useCatalogUiState();
|
||||
const catalogAdmin = useCatalogAdmin();
|
||||
const adminMode = catalogAdmin?.adminMode ?? false;
|
||||
const { isFavoritePage, toggleFavoritePage } = useCatalogFavorites();
|
||||
@@ -100,8 +101,10 @@ export const CatalogNavigationItemView: FC<CatalogNavigationItemViewProps> = pro
|
||||
e.stopPropagation();
|
||||
catalogAdmin.createPage({
|
||||
caption: 'New Page',
|
||||
captionSave: 'New Page',
|
||||
catalogMode: currentType,
|
||||
pageLayout: 'default_3x3',
|
||||
iconImage: 0,
|
||||
minRank: 1,
|
||||
visible: '1',
|
||||
enabled: '1',
|
||||
@@ -125,8 +128,11 @@ export const CatalogNavigationItemView: FC<CatalogNavigationItemViewProps> = pro
|
||||
</div> }
|
||||
{ !adminMode && node.pageId > 0 &&
|
||||
<FaStar
|
||||
className={ `nitro-catalog-classic-navigation-favorite text-[8px] transition-all duration-100 cursor-pointer shrink-0 ${ isFav ? 'text-warning opacity-100' : 'text-muted opacity-0 group-hover/nav:opacity-100 hover:text-warning' }` }
|
||||
onClick={ e => { e.stopPropagation(); toggleFavoritePage(node.pageId); } }
|
||||
className={ `text-[8px] transition-all duration-100 cursor-pointer shrink-0 ${ isFav ? 'text-warning opacity-100' : 'text-muted opacity-0 group-hover/nav:opacity-100 hover:text-warning' }` }
|
||||
onClick={ e =>
|
||||
{
|
||||
e.stopPropagation(); toggleFavoritePage(node.pageId);
|
||||
} }
|
||||
/> }
|
||||
{ node.isBranch &&
|
||||
<span className="nitro-catalog-classic-navigation-caret text-[9px] text-muted shrink-0">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FC } from 'react';
|
||||
import { ICatalogNode } from '../../../../api';
|
||||
import { useCatalog } from '../../../../hooks';
|
||||
import { useCatalogData } from '../../../../hooks';
|
||||
import { CatalogNavigationItemView } from './CatalogNavigationItemView';
|
||||
import { CatalogNavigationSetView } from './CatalogNavigationSetView';
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface CatalogNavigationViewProps
|
||||
export const CatalogNavigationView: FC<CatalogNavigationViewProps> = props =>
|
||||
{
|
||||
const { node = null } = props;
|
||||
const { searchResult = null } = useCatalog();
|
||||
const { searchResult = null } = useCatalogData();
|
||||
|
||||
return (
|
||||
<div className="nitro-catalog-classic-navigation-list">
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FC, MouseEvent, useMemo, useState } from 'react';
|
||||
import { FaHeart } from 'react-icons/fa';
|
||||
import { CatalogType, IPurchasableOffer, Offer, ProductTypeEnum } from '../../../../../api';
|
||||
import { LayoutAvatarImageView, LayoutGridItem, LayoutGridItemProps } from '../../../../../common';
|
||||
import { useCatalog, useCatalogFavorites, useInventoryFurni } from '../../../../../hooks';
|
||||
import { useCatalogActions, useCatalogFavorites, useCatalogUiState, useInventoryFurni } from '../../../../../hooks';
|
||||
|
||||
interface CatalogGridOfferViewProps extends LayoutGridItemProps
|
||||
{
|
||||
@@ -15,7 +15,8 @@ export const CatalogGridOfferView: FC<CatalogGridOfferViewProps> = props =>
|
||||
{
|
||||
const { offer = null, selectOffer = null, itemActive = false, ...rest } = props;
|
||||
const [ isMouseDown, setMouseDown ] = useState(false);
|
||||
const { requestOfferToMover = null, currentType = CatalogType.NORMAL } = useCatalog();
|
||||
const { requestOfferToMover = null } = useCatalogActions();
|
||||
const { currentType = CatalogType.NORMAL } = useCatalogUiState();
|
||||
const { isVisible = false } = useInventoryFurni();
|
||||
const { isFavoriteOffer, toggleFavoriteOffer } = useCatalogFavorites();
|
||||
const isFav = offer ? isFavoriteOffer(offer.offerId) : false;
|
||||
@@ -78,7 +79,10 @@ export const CatalogGridOfferView: FC<CatalogGridOfferViewProps> = props =>
|
||||
<LayoutAvatarImageView direction={ 3 } figure={ offer.product.extraParam } headOnly={ true } /> }
|
||||
<div
|
||||
className={ `absolute top-0 right-0 z-10 p-0.5 cursor-pointer transition-opacity duration-100 ${ isFav ? 'opacity-100' : 'opacity-0 group-hover/tile:opacity-100' }` }
|
||||
onClick={ e => { e.stopPropagation(); e.preventDefault(); toggleFavoriteOffer(offer.offerId, offer.localizationName, iconUrl); } }
|
||||
onClick={ e =>
|
||||
{
|
||||
e.stopPropagation(); e.preventDefault(); toggleFavoriteOffer(offer.offerId, offer.localizationName, iconUrl);
|
||||
} }
|
||||
onMouseDown={ e => e.stopPropagation() }
|
||||
>
|
||||
<FaHeart className={ `text-[10px] drop-shadow transition-colors duration-100 ${ isFav ? 'text-danger' : 'text-muted hover:text-danger' }` } />
|
||||
|
||||
@@ -2,12 +2,13 @@ import { GetSessionDataManager, IFurnitureData } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { FaSearch, FaTimes } from 'react-icons/fa';
|
||||
import { CatalogPage, CatalogType, FilterCatalogNode, FurnitureOffer, ICatalogNode, ICatalogPage, IPurchasableOffer, LocalizeText, PageLocalization, SearchResult } from '../../../../../api';
|
||||
import { useCatalog } from '../../../../../hooks';
|
||||
import { useCatalogData, useCatalogUiState } from '../../../../../hooks';
|
||||
|
||||
export const CatalogSearchView: FC<{}> = () =>
|
||||
{
|
||||
const [ searchValue, setSearchValue ] = useState('');
|
||||
const { currentType = null, rootNode = null, searchResult = null, setSearchResult = null, setCurrentPage = null } = useCatalog();
|
||||
const { rootNode = null, searchResult = null } = useCatalogData();
|
||||
const { currentType = null, setSearchResult = null, setCurrentPage = null } = useCatalogUiState();
|
||||
|
||||
const normalizeSearchText = (value: string) => (value || '')
|
||||
.toLocaleLowerCase()
|
||||
@@ -48,6 +49,7 @@ export const CatalogSearchView: FC<{}> = () =>
|
||||
|
||||
const name = normalizeSearchText(furniture.name || '');
|
||||
const matchesSearch = name.includes(search);
|
||||
const isBuyable = (furniture.purchaseOfferId > -1) || (furniture.rentOfferId > -1);
|
||||
|
||||
if((currentType === CatalogType.BUILDER) && (furniture.purchaseOfferId === -1) && (furniture.rentOfferId === -1))
|
||||
{
|
||||
@@ -56,7 +58,7 @@ export const CatalogSearchView: FC<{}> = () =>
|
||||
if(matchesSearch) foundFurniLines.push(furniture.furniLine);
|
||||
}
|
||||
}
|
||||
else if(matchesSearch)
|
||||
else if(matchesSearch && isBuyable)
|
||||
{
|
||||
foundFurniture.push(furniture);
|
||||
|
||||
@@ -67,6 +69,10 @@ export const CatalogSearchView: FC<{}> = () =>
|
||||
|
||||
if(foundFurniture.length === 250) break;
|
||||
}
|
||||
else if(matchesSearch && furniture.furniLine && furniture.furniLine.length && (foundFurniLines.indexOf(furniture.furniLine) < 0))
|
||||
{
|
||||
foundFurniLines.push(furniture.furniLine);
|
||||
}
|
||||
}
|
||||
|
||||
const offers: IPurchasableOffer[] = [];
|
||||
@@ -81,7 +87,7 @@ export const CatalogSearchView: FC<{}> = () =>
|
||||
FilterCatalogNode(search, foundFurniLines, rootNode, nodes);
|
||||
|
||||
setSearchResult(new SearchResult(search, offers, nodes.filter(node => (node.isVisible))));
|
||||
setCurrentPage((new CatalogPage(-1, 'default_3x3', new PageLocalization([], []), offers, false, 1) as ICatalogPage));
|
||||
setCurrentPage((new CatalogPage(-1, 'default_3x3', new PageLocalization([], []), offers, false, 1)));
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FC } from 'react';
|
||||
import { LocalizeText, SanitizeHtml } from '../../../../../api';
|
||||
import { Column, Grid, Text } from '../../../../../common';
|
||||
import { useCatalog } from '../../../../../hooks';
|
||||
import { useCatalogData } from '../../../../../hooks';
|
||||
import { CatalogBadgeSelectorWidgetView } from '../widgets/CatalogBadgeSelectorWidgetView';
|
||||
import { CatalogFirstProductSelectorWidgetView } from '../widgets/CatalogFirstProductSelectorWidgetView';
|
||||
import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView';
|
||||
@@ -14,7 +14,7 @@ import { CatalogLayoutProps } from './CatalogLayout.types';
|
||||
export const CatalogLayoutBadgeDisplayView: FC<CatalogLayoutProps> = props =>
|
||||
{
|
||||
const { page = null } = props;
|
||||
const { currentOffer = null } = useCatalog();
|
||||
const { currentOffer = null } = useCatalogData();
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ClubOfferData, GetClubOffersMessageComposer, PurchaseFromCatalogComposer } from '@nitrots/nitro-renderer';
|
||||
import { ClubOfferData, PurchaseFromCatalogComposer } from '@nitrots/nitro-renderer';
|
||||
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { CatalogPurchaseState, LocalizeText, SanitizeHtml, SendMessageComposer } from '../../../../../api';
|
||||
import { Button, Column, Flex, Grid, LayoutCurrencyIcon, LayoutGridItem, LayoutLoadingSpinnerView, Text } from '../../../../../common';
|
||||
import { CatalogEvent, CatalogPurchaseFailureEvent, CatalogPurchasedEvent } from '../../../../../events';
|
||||
import { useCatalog, usePurse, useUiEvent } from '../../../../../hooks';
|
||||
import { useCatalogData, useClubOffers, usePurse, useUiEvent } from '../../../../../hooks';
|
||||
import { CatalogHeaderView } from '../../catalog-header/CatalogHeaderView';
|
||||
import { CatalogLayoutProps } from './CatalogLayout.types';
|
||||
|
||||
@@ -14,12 +14,12 @@ export const CatalogLayoutBuildersClubBuyView: FC<CatalogLayoutProps> = () =>
|
||||
{
|
||||
const [ pendingOffer, setPendingOffer ] = useState<ClubOfferData>(null);
|
||||
const [ purchaseState, setPurchaseState ] = useState(CatalogPurchaseState.NONE);
|
||||
const { currentPage = null, catalogOptions = null } = useCatalog();
|
||||
const { currentPage = null } = useCatalogData();
|
||||
const { getCurrencyAmount = null } = usePurse();
|
||||
const isPurchasingRef = useRef(false);
|
||||
const isAddonLayout = (currentPage?.layoutCode === 'builders_club_addons');
|
||||
const windowId = (isAddonLayout ? BUILDERS_CLUB_ADDONS_WINDOW_ID : BUILDERS_CLUB_WINDOW_ID);
|
||||
const offers = catalogOptions?.clubOffersByWindowId?.[windowId] || null;
|
||||
const { data: offers = null } = useClubOffers(windowId);
|
||||
|
||||
const onCatalogEvent = useCallback((event: CatalogEvent) =>
|
||||
{
|
||||
@@ -120,11 +120,6 @@ export const CatalogLayoutBuildersClubBuyView: FC<CatalogLayoutProps> = () =>
|
||||
return currentPage.localization.getText(1) || currentPage.localization.getText(2) || currentPage.localization.getText(0) || '';
|
||||
}, [ currentPage ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!offers) SendMessageComposer(new GetClubOffersMessageComposer(windowId));
|
||||
}, [ offers, windowId ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!offers || !offers.length) return;
|
||||
@@ -142,44 +137,45 @@ export const CatalogLayoutBuildersClubBuyView: FC<CatalogLayoutProps> = () =>
|
||||
{ currentPage?.localization?.getImage(0) &&
|
||||
<CatalogHeaderView imageUrl={ currentPage.localization.getImage(0) } /> }
|
||||
<Grid>
|
||||
<Column fullHeight justifyContent="between" overflow="hidden" size={ 7 }>
|
||||
<Column gap={ 1 } overflow="auto">
|
||||
{ offers && (offers.length > 0) && offers.map((offer, index) =>
|
||||
{
|
||||
const meta = getOfferMeta(offer);
|
||||
<Column fullHeight justifyContent="between" overflow="hidden" size={ 7 }>
|
||||
<Column gap={ 1 } overflow="auto">
|
||||
{ offers && (offers.length > 0) && offers.map((offer, index) =>
|
||||
{
|
||||
const meta = getOfferMeta(offer);
|
||||
|
||||
return (
|
||||
<LayoutGridItem key={ index } alignItems="center" center={ false } className="p-2" column={ false } itemActive={ pendingOffer?.offerId === offer.offerId } justifyContent="between" onClick={ () => {
|
||||
setPurchaseState(CatalogPurchaseState.NONE);
|
||||
setPendingOffer(offer);
|
||||
} }>
|
||||
<Column gap={ 0 }>
|
||||
<Text fontWeight="bold">{ getOfferName(offer) }</Text>
|
||||
{ meta.length > 0 && <Text small>{ meta }</Text> }
|
||||
</Column>
|
||||
<div className="flex flex-col gap-1">
|
||||
{ (offer.priceCredits > 0) &&
|
||||
return (
|
||||
<LayoutGridItem key={ index } alignItems="center" center={ false } className="p-2" column={ false } itemActive={ pendingOffer?.offerId === offer.offerId } justifyContent="between" onClick={ () =>
|
||||
{
|
||||
setPurchaseState(CatalogPurchaseState.NONE);
|
||||
setPendingOffer(offer);
|
||||
} }>
|
||||
<Column gap={ 0 }>
|
||||
<Text fontWeight="bold">{ getOfferName(offer) }</Text>
|
||||
{ meta.length > 0 && <Text small>{ meta }</Text> }
|
||||
</Column>
|
||||
<div className="flex flex-col gap-1">
|
||||
{ (offer.priceCredits > 0) &&
|
||||
<Flex alignItems="center" gap={ 1 } justifyContent="end">
|
||||
<Text>{ offer.priceCredits }</Text>
|
||||
<LayoutCurrencyIcon type={ -1 } />
|
||||
</Flex> }
|
||||
{ (offer.priceActivityPoints > 0) &&
|
||||
{ (offer.priceActivityPoints > 0) &&
|
||||
<Flex alignItems="center" gap={ 1 } justifyContent="end">
|
||||
<Text>{ offer.priceActivityPoints }</Text>
|
||||
<LayoutCurrencyIcon type={ offer.priceActivityPointsType } />
|
||||
</Flex> }
|
||||
</div>
|
||||
</LayoutGridItem>
|
||||
);
|
||||
}) }
|
||||
</div>
|
||||
</LayoutGridItem>
|
||||
);
|
||||
}) }
|
||||
</Column>
|
||||
</Column>
|
||||
</Column>
|
||||
<Column gap={ 2 } overflow="hidden" size={ 5 }>
|
||||
<Column center grow overflow="hidden">
|
||||
{ currentPage?.localization.getImage(1) && <img alt="" src={ currentPage.localization.getImage(1) } /> }
|
||||
{ pageDescription.length > 0 && <Text center dangerouslySetInnerHTML={ { __html: SanitizeHtml(pageDescription) } } overflow="auto" /> }
|
||||
</Column>
|
||||
{ pendingOffer &&
|
||||
<Column gap={ 2 } overflow="hidden" size={ 5 }>
|
||||
<Column center grow overflow="hidden">
|
||||
{ currentPage?.localization.getImage(1) && <img alt="" src={ currentPage.localization.getImage(1) } /> }
|
||||
{ pageDescription.length > 0 && <Text center dangerouslySetInnerHTML={ { __html: SanitizeHtml(pageDescription) } } overflow="auto" /> }
|
||||
</Column>
|
||||
{ pendingOffer &&
|
||||
<Column fullWidth gap={ 1 }>
|
||||
<Text fontWeight="bold">{ getOfferName(pendingOffer) }</Text>
|
||||
{ getOfferMeta(pendingOffer).length > 0 && <Text>{ getOfferMeta(pendingOffer) }</Text> }
|
||||
@@ -202,7 +198,7 @@ export const CatalogLayoutBuildersClubBuyView: FC<CatalogLayoutProps> = () =>
|
||||
</Flex>
|
||||
{ getPurchaseButton() }
|
||||
</Column> }
|
||||
</Column>
|
||||
</Column>
|
||||
</Grid>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FC, useMemo, useState } from 'react';
|
||||
import { FaFillDrip } from 'react-icons/fa';
|
||||
import { IPurchasableOffer, SanitizeHtml } from '../../../../../api';
|
||||
import { AutoGrid, Button, Column, Grid, LayoutGridItem, Text } from '../../../../../common';
|
||||
import { useCatalog } from '../../../../../hooks';
|
||||
import { useCatalogData, useCatalogUiState } from '../../../../../hooks';
|
||||
import { CatalogGridOfferView } from '../common/CatalogGridOfferView';
|
||||
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
|
||||
import { CatalogLimitedItemWidgetView } from '../widgets/CatalogLimitedItemWidgetView';
|
||||
@@ -22,7 +22,8 @@ export const CatalogLayoutColorGroupingView: FC<CatalogLayoutColorGroupViewProps
|
||||
{
|
||||
const { page = null } = props;
|
||||
const [ colorableItems, setColorableItems ] = useState<Map<string, number[]>>(new Map<string, number[]>());
|
||||
const { currentOffer = null, setCurrentOffer = null } = useCatalog();
|
||||
const { currentOffer = null } = useCatalogData();
|
||||
const { setCurrentOffer = null } = useCatalogUiState();
|
||||
const [ colorsShowing, setColorsShowing ] = useState<boolean>(false);
|
||||
|
||||
const sortByColorIndex = (a: IPurchasableOffer, b: IPurchasableOffer) =>
|
||||
|
||||
@@ -117,7 +117,10 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
|
||||
if(!prefixText.length) return;
|
||||
|
||||
const newColors: Record<number, string> = {};
|
||||
[ ...prefixText ].forEach((_, i) => { newColors[i] = customColorInput; });
|
||||
[ ...prefixText ].forEach((_, i) =>
|
||||
{
|
||||
newColors[i] = customColorInput;
|
||||
});
|
||||
setLetterColors(newColors);
|
||||
};
|
||||
|
||||
@@ -222,7 +225,10 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
|
||||
<Picker
|
||||
data={ data }
|
||||
locale="it"
|
||||
onEmojiSelect={ (emoji: { native: string }) => { setSelectedIcon(emoji.native); setShowIconPicker(false); } }
|
||||
onEmojiSelect={ (emoji: { native: string }) =>
|
||||
{
|
||||
setSelectedIcon(emoji.native); setShowIconPicker(false);
|
||||
} }
|
||||
theme="dark"
|
||||
previewPosition="none"
|
||||
skinTonePosition="search"
|
||||
@@ -268,7 +274,10 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
|
||||
borderRight: '1px solid rgba(0,0,0,0.1)',
|
||||
opacity: colorMode === 'single' ? 1 : 0.6
|
||||
} }
|
||||
onClick={ () => { setColorMode('single'); setSelectedLetterIndex(null); } }>
|
||||
onClick={ () =>
|
||||
{
|
||||
setColorMode('single'); setSelectedLetterIndex(null);
|
||||
} }>
|
||||
{ LocalizeText('catalog.prefix.color.single') }
|
||||
</button>
|
||||
<button
|
||||
@@ -277,7 +286,10 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
|
||||
background: colorMode === 'perLetter' ? 'rgba(59,130,246,0.25)' : 'rgba(0,0,0,0.1)',
|
||||
opacity: colorMode === 'perLetter' ? 1 : 0.6
|
||||
} }
|
||||
onClick={ () => { setColorMode('perLetter'); if(prefixText.length > 0) setSelectedLetterIndex(0); } }>
|
||||
onClick={ () =>
|
||||
{
|
||||
setColorMode('perLetter'); if(prefixText.length > 0) setSelectedLetterIndex(0);
|
||||
} }>
|
||||
{ LocalizeText('catalog.prefix.color.per.letter') }
|
||||
</button>
|
||||
</div>
|
||||
@@ -328,7 +340,10 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
|
||||
zIndex: isSelected ? 10 : 1,
|
||||
boxShadow: isSelected ? '0 0 8px rgba(59,130,246,0.3)' : 'none'
|
||||
} }
|
||||
onClick={ () => { setSelectedLetterIndex(i); setCustomColorInput(charColor); } }>
|
||||
onClick={ () =>
|
||||
{
|
||||
setSelectedLetterIndex(i); setCustomColorInput(charColor);
|
||||
} }>
|
||||
<span className="text-sm font-black" style={ { color: charColor } }>
|
||||
{ char }
|
||||
</span>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { FC } from 'react';
|
||||
import { FaEdit, FaPlus } from 'react-icons/fa';
|
||||
import { GetConfigurationValue, LocalizeText, ProductTypeEnum, SanitizeHtml } from '../../../../../api';
|
||||
import { Text } from '../../../../../common';
|
||||
import { useCatalog } from '../../../../../hooks';
|
||||
import { useCatalogData } from '../../../../../hooks';
|
||||
import { useCatalogAdmin } from '../../../CatalogAdminContext';
|
||||
import { CatalogHeaderView } from '../../catalog-header/CatalogHeaderView';
|
||||
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
|
||||
@@ -17,7 +17,7 @@ import { CatalogLayoutProps } from './CatalogLayout.types';
|
||||
export const CatalogLayoutDefaultView: FC<CatalogLayoutProps> = props =>
|
||||
{
|
||||
const { page = null } = props;
|
||||
const { currentOffer = null, currentPage = null } = useCatalog();
|
||||
const { currentOffer = null, currentPage = null } = useCatalogData();
|
||||
const catalogAdmin = useCatalogAdmin();
|
||||
const adminMode = catalogAdmin?.adminMode ?? false;
|
||||
|
||||
@@ -28,7 +28,10 @@ export const CatalogLayoutDefaultView: FC<CatalogLayoutProps> = props =>
|
||||
<div className="flex gap-2 nitro-catalog-classic-default-admin">
|
||||
<button
|
||||
className="flex items-center gap-1 text-[10px] text-primary hover:text-dark transition-colors cursor-pointer"
|
||||
onClick={ () => { catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true); } }
|
||||
onClick={ () =>
|
||||
{
|
||||
catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true);
|
||||
} }
|
||||
>
|
||||
<FaEdit className="text-[10px]" /> { LocalizeText('catalog.admin.edit.page') }
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FC } from 'react';
|
||||
import { SanitizeHtml } from '../../../../../api';
|
||||
import { Column, Grid, Text } from '../../../../../common';
|
||||
import { useCatalog } from '../../../../../hooks';
|
||||
import { useCatalogData } from '../../../../../hooks';
|
||||
import { CatalogGuildBadgeWidgetView } from '../widgets/CatalogGuildBadgeWidgetView';
|
||||
import { CatalogGuildSelectorWidgetView } from '../widgets/CatalogGuildSelectorWidgetView';
|
||||
import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView';
|
||||
@@ -13,7 +13,7 @@ import { CatalogLayoutProps } from './CatalogLayout.types';
|
||||
export const CatalogLayouGuildCustomFurniView: FC<CatalogLayoutProps> = props =>
|
||||
{
|
||||
const { page = null } = props;
|
||||
const { currentOffer = null } = useCatalog();
|
||||
const { currentOffer = null } = useCatalogData();
|
||||
|
||||
return (
|
||||
<Grid>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { CatalogGroupsComposer } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { SanitizeHtml, SendMessageComposer } from '../../../../../api';
|
||||
import { FC, useState } from 'react';
|
||||
import { SanitizeHtml } from '../../../../../api';
|
||||
import { Column, Grid, Text } from '../../../../../common';
|
||||
import { useCatalog } from '../../../../../hooks';
|
||||
import { useCatalogData, useCatalogUiState, useUserGroups } from '../../../../../hooks';
|
||||
import { CatalogFirstProductSelectorWidgetView } from '../widgets/CatalogFirstProductSelectorWidgetView';
|
||||
import { CatalogGuildSelectorWidgetView } from '../widgets/CatalogGuildSelectorWidgetView';
|
||||
import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView';
|
||||
@@ -13,13 +12,9 @@ export const CatalogLayouGuildForumView: FC<CatalogLayoutProps> = props =>
|
||||
{
|
||||
const { page = null } = props;
|
||||
const [ selectedGroupIndex, setSelectedGroupIndex ] = useState<number>(0);
|
||||
const { currentOffer = null, setCurrentOffer = null, catalogOptions = null } = useCatalog();
|
||||
const { groups = null } = catalogOptions;
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
SendMessageComposer(new CatalogGroupsComposer());
|
||||
}, [ page ]);
|
||||
const { currentOffer = null } = useCatalogData();
|
||||
const { setCurrentOffer = null } = useCatalogUiState();
|
||||
const { data: groups = null } = useUserGroups();
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { GetRoomAdPurchaseInfoComposer, GetUserEventCatsMessageComposer, PurchaseRoomAdMessageComposer, RoomAdPurchaseInfoEvent, RoomEntryData } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { LocalizeText, SendMessageComposer } from '../../../../../api';
|
||||
import { useNitroQuery } from '../../../../../api/nitro-query';
|
||||
import { Button, Column, Text } from '../../../../../common';
|
||||
import { useCatalog, useMessageEvent, useNavigator, useRoomPromote } from '../../../../../hooks';
|
||||
import { useCatalogUiState, useNavigator, useRoomPromote } from '../../../../../hooks';
|
||||
import { NitroInput } from '../../../../../layout';
|
||||
import { CatalogLayoutProps } from './CatalogLayout.types';
|
||||
|
||||
@@ -14,13 +15,20 @@ export const CatalogLayoutRoomAdsView: FC<CatalogLayoutProps> = props =>
|
||||
const [ eventName, setEventName ] = useState<string>('');
|
||||
const [ eventDesc, setEventDesc ] = useState<string>('');
|
||||
const [ roomId, setRoomId ] = useState<number>(-1);
|
||||
const [ availableRooms, setAvailableRooms ] = useState<RoomEntryData[]>([]);
|
||||
const [ extended, setExtended ] = useState<boolean>(false);
|
||||
const [ categoryId, setCategoryId ] = useState<number>(1);
|
||||
const { categories = null } = useNavigator();
|
||||
const { setIsVisible = null } = useCatalog();
|
||||
const { setIsVisible = null } = useCatalogUiState();
|
||||
const { promoteInformation, isExtended, setIsExtended } = useRoomPromote();
|
||||
|
||||
const { data: availableRooms = [] } = useNitroQuery<RoomAdPurchaseInfoEvent, RoomEntryData[]>({
|
||||
key: [ 'nitro', 'catalog', 'room-ad-purchase-info' ],
|
||||
request: () => new GetRoomAdPurchaseInfoComposer(),
|
||||
parser: RoomAdPurchaseInfoEvent,
|
||||
select: e => e.getParser()?.rooms ?? [],
|
||||
staleTime: 60_000
|
||||
});
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(isExtended)
|
||||
@@ -62,18 +70,8 @@ export const CatalogLayoutRoomAdsView: FC<CatalogLayoutProps> = props =>
|
||||
resetData();
|
||||
};
|
||||
|
||||
useMessageEvent<RoomAdPurchaseInfoEvent>(RoomAdPurchaseInfoEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
if(!parser) return;
|
||||
|
||||
setAvailableRooms(parser.rooms);
|
||||
});
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
SendMessageComposer(new GetRoomAdPurchaseInfoComposer());
|
||||
// TODO: someone needs to fix this for morningstar
|
||||
SendMessageComposer(new GetUserEventCatsMessageComposer());
|
||||
}, []);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { GetOfficialSongIdMessageComposer, GetSoundManager, MusicPriorities, Off
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { GetConfigurationValue, LocalizeText, ProductTypeEnum, SanitizeHtml, SendMessageComposer } from '../../../../../api';
|
||||
import { Button, Column, Grid, LayoutImage, Text } from '../../../../../common';
|
||||
import { useCatalog, useMessageEvent } from '../../../../../hooks';
|
||||
import { useCatalogData, useMessageEvent } from '../../../../../hooks';
|
||||
import { CatalogHeaderView } from '../../catalog-header/CatalogHeaderView';
|
||||
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
|
||||
import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView';
|
||||
@@ -18,7 +18,7 @@ export const CatalogLayoutSoundMachineView: FC<CatalogLayoutProps> = props =>
|
||||
const { page = null } = props;
|
||||
const [ songId, setSongId ] = useState(-1);
|
||||
const [ officialSongId, setOfficialSongId ] = useState('');
|
||||
const { currentOffer = null, currentPage = null } = useCatalog();
|
||||
const { currentOffer = null, currentPage = null } = useCatalogData();
|
||||
|
||||
const previewSong = (previewSongId: number) => GetSoundManager().musicController?.playSong(previewSongId, MusicPriorities.PRIORITY_PURCHASE_PREVIEW, 15, 0, 0, 0);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FC, useEffect } from 'react';
|
||||
import { SanitizeHtml } from '../../../../../api';
|
||||
import { Column, Grid, Text } from '../../../../../common';
|
||||
import { useCatalog } from '../../../../../hooks';
|
||||
import { useCatalogData } from '../../../../../hooks';
|
||||
import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView';
|
||||
import { CatalogSpacesWidgetView } from '../widgets/CatalogSpacesWidgetView';
|
||||
import { CatalogTotalPriceWidget } from '../widgets/CatalogTotalPriceWidget';
|
||||
@@ -11,7 +11,7 @@ import { CatalogLayoutProps } from './CatalogLayout.types';
|
||||
export const CatalogLayoutSpacesView: FC<CatalogLayoutProps> = props =>
|
||||
{
|
||||
const { page = null } = props;
|
||||
const { currentOffer = null, roomPreviewer = null } = useCatalog();
|
||||
const { currentOffer = null, roomPreviewer = null } = useCatalogData();
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@ import { FC, useEffect, useState } from 'react';
|
||||
import { FaEdit, FaPen, FaPlus, FaTrophy } from 'react-icons/fa';
|
||||
import { LocalizeText, ProductTypeEnum, SanitizeHtml } from '../../../../../api';
|
||||
import { Text } from '../../../../../common';
|
||||
import { useCatalog } from '../../../../../hooks';
|
||||
import { useCatalogData, useCatalogUiState } from '../../../../../hooks';
|
||||
import { useCatalogAdmin } from '../../../CatalogAdminContext';
|
||||
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
|
||||
import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView';
|
||||
@@ -15,7 +15,8 @@ export const CatalogLayoutTrophiesView: FC<CatalogLayoutProps> = props =>
|
||||
{
|
||||
const { page = null } = props;
|
||||
const [ trophyText, setTrophyText ] = useState<string>('');
|
||||
const { currentOffer = null, setPurchaseOptions = null } = useCatalog();
|
||||
const { currentOffer = null } = useCatalogData();
|
||||
const { setPurchaseOptions = null } = useCatalogUiState();
|
||||
const catalogAdmin = useCatalogAdmin();
|
||||
const adminMode = catalogAdmin?.adminMode ?? false;
|
||||
|
||||
@@ -42,7 +43,10 @@ export const CatalogLayoutTrophiesView: FC<CatalogLayoutProps> = props =>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="flex items-center gap-1 text-[10px] text-primary hover:text-dark transition-colors cursor-pointer"
|
||||
onClick={ () => { catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true); } }
|
||||
onClick={ () =>
|
||||
{
|
||||
catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true);
|
||||
} }
|
||||
>
|
||||
<FaEdit className="text-[10px]" /> { LocalizeText('catalog.admin.edit.page') }
|
||||
</button>
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
import { ClubOfferData, GetClubOffersMessageComposer, PurchaseFromCatalogComposer } from '@nitrots/nitro-renderer';
|
||||
import { ClubOfferData, GiftReceiverNotFoundEvent, PurchaseFromCatalogAsGiftComposer, PurchaseFromCatalogComposer } from '@nitrots/nitro-renderer';
|
||||
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { CatalogPurchaseState, LocalizeText, SanitizeHtml, SendMessageComposer } from '../../../../../api';
|
||||
import { AutoGrid, Button, Column, Flex, Grid, LayoutCurrencyIcon, LayoutGridItem, LayoutLoadingSpinnerView, Text } from '../../../../../common';
|
||||
import { AutoGrid, Button, Column, Flex, Grid, LayoutCurrencyIcon, LayoutLoadingSpinnerView, Text } from '../../../../../common';
|
||||
import { CatalogEvent, CatalogPurchaseFailureEvent, CatalogPurchasedEvent } from '../../../../../events';
|
||||
import { useCatalog, usePurse, useUiEvent } from '../../../../../hooks';
|
||||
import { useCatalogData, useClubOffers, useMessageEvent, usePurse, useUiEvent, useUserDataSnapshot } from '../../../../../hooks';
|
||||
import { CatalogLayoutProps } from './CatalogLayout.types';
|
||||
|
||||
const VIP_WINDOW_ID = 1;
|
||||
|
||||
export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
|
||||
{
|
||||
const [ pendingOffer, setPendingOffer ] = useState<ClubOfferData>(null);
|
||||
const [ purchaseState, setPurchaseState ] = useState(CatalogPurchaseState.NONE);
|
||||
const { currentPage = null, catalogOptions = null } = useCatalog();
|
||||
const [ giftMode, setGiftMode ] = useState(false);
|
||||
const [ giftRecipient, setGiftRecipient ] = useState('');
|
||||
const [ giftError, setGiftError ] = useState<string | null>(null);
|
||||
const [ giftSuccess, setGiftSuccess ] = useState(false);
|
||||
const { currentPage = null } = useCatalogData();
|
||||
const { purse = null, getCurrencyAmount = null } = usePurse();
|
||||
const { clubOffers = null, clubOffersByWindowId = null } = (catalogOptions || {});
|
||||
const offers = clubOffersByWindowId?.[1] || clubOffers;
|
||||
const { data: offers = null } = useClubOffers(VIP_WINDOW_ID);
|
||||
const { userName: ownUserName = '' } = useUserDataSnapshot();
|
||||
const isPurchasingRef = useRef<boolean>(false);
|
||||
const wasGiftPurchaseRef = useRef<boolean>(false);
|
||||
const giftSuccessTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const isSelfGift = giftMode && !!ownUserName && giftRecipient.trim().toLowerCase() === ownUserName.toLowerCase();
|
||||
|
||||
const onCatalogEvent = useCallback((event: CatalogEvent) =>
|
||||
{
|
||||
@@ -23,9 +33,20 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
|
||||
case CatalogPurchasedEvent.PURCHASE_SUCCESS:
|
||||
isPurchasingRef.current = false;
|
||||
setPurchaseState(CatalogPurchaseState.NONE);
|
||||
setGiftError(null);
|
||||
if(wasGiftPurchaseRef.current)
|
||||
{
|
||||
wasGiftPurchaseRef.current = false;
|
||||
setGiftRecipient('');
|
||||
setGiftMode(false);
|
||||
setGiftSuccess(true);
|
||||
if(giftSuccessTimerRef.current) clearTimeout(giftSuccessTimerRef.current);
|
||||
giftSuccessTimerRef.current = setTimeout(() => setGiftSuccess(false), 3500);
|
||||
}
|
||||
return;
|
||||
case CatalogPurchaseFailureEvent.PURCHASE_FAILED:
|
||||
isPurchasingRef.current = false;
|
||||
wasGiftPurchaseRef.current = false;
|
||||
setPurchaseState(CatalogPurchaseState.FAILED);
|
||||
return;
|
||||
}
|
||||
@@ -34,6 +55,21 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
|
||||
useUiEvent(CatalogPurchasedEvent.PURCHASE_SUCCESS, onCatalogEvent);
|
||||
useUiEvent(CatalogPurchaseFailureEvent.PURCHASE_FAILED, onCatalogEvent);
|
||||
|
||||
useEffect(() => () =>
|
||||
{
|
||||
if(giftSuccessTimerRef.current) clearTimeout(giftSuccessTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const handleGiftReceiverNotFound = useCallback(() =>
|
||||
{
|
||||
if(!isPurchasingRef.current) return;
|
||||
isPurchasingRef.current = false;
|
||||
setPurchaseState(CatalogPurchaseState.NONE);
|
||||
setGiftError(LocalizeText('catalog.gift_wrapping.receiver_not_found.title'));
|
||||
}, []);
|
||||
|
||||
useMessageEvent<GiftReceiverNotFoundEvent>(GiftReceiverNotFoundEvent, handleGiftReceiverNotFound);
|
||||
|
||||
const getOfferText = useCallback((offer: ClubOfferData) =>
|
||||
{
|
||||
let offerText = '';
|
||||
@@ -88,16 +124,39 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
|
||||
const purchaseSubscription = useCallback(() =>
|
||||
{
|
||||
if(!pendingOffer || isPurchasingRef.current) return;
|
||||
if(giftMode && !giftRecipient.trim()) return;
|
||||
if(isSelfGift) return;
|
||||
|
||||
isPurchasingRef.current = true;
|
||||
wasGiftPurchaseRef.current = giftMode;
|
||||
setPurchaseState(CatalogPurchaseState.PURCHASE);
|
||||
SendMessageComposer(new PurchaseFromCatalogComposer(currentPage.pageId, pendingOffer.offerId, null, 1));
|
||||
}, [ pendingOffer, currentPage ]);
|
||||
setGiftError(null);
|
||||
setGiftSuccess(false);
|
||||
|
||||
if(giftMode)
|
||||
{
|
||||
SendMessageComposer(new PurchaseFromCatalogAsGiftComposer(currentPage.pageId, pendingOffer.offerId, '', giftRecipient.trim(), '', 0, 0, 0, false));
|
||||
}
|
||||
else
|
||||
{
|
||||
SendMessageComposer(new PurchaseFromCatalogComposer(currentPage.pageId, pendingOffer.offerId, null, 1));
|
||||
}
|
||||
}, [ pendingOffer, currentPage, giftMode, giftRecipient, isSelfGift ]);
|
||||
|
||||
const setOffer = useCallback((offer: ClubOfferData) =>
|
||||
{
|
||||
setPurchaseState(CatalogPurchaseState.NONE);
|
||||
setPendingOffer(offer);
|
||||
setGiftError(null);
|
||||
setGiftSuccess(false);
|
||||
if(!offer?.giftable) setGiftMode(false);
|
||||
}, []);
|
||||
|
||||
const onGiftRecipientChange = useCallback((value: string) =>
|
||||
{
|
||||
setGiftRecipient(value);
|
||||
setGiftError(null);
|
||||
setGiftSuccess(false);
|
||||
}, []);
|
||||
|
||||
const getPurchaseButton = useCallback(() =>
|
||||
@@ -114,24 +173,22 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
|
||||
return <Button fullWidth variant="danger">{ LocalizeText('catalog.alert.notenough.activitypoints.title.' + pendingOffer.priceActivityPointsType) }</Button>;
|
||||
}
|
||||
|
||||
const giftBlocked = giftMode && (!giftRecipient.trim() || isSelfGift);
|
||||
const buyLabel = giftMode ? LocalizeText('catalog.gift_wrapping.give_gift') : LocalizeText('buy');
|
||||
|
||||
switch(purchaseState)
|
||||
{
|
||||
case CatalogPurchaseState.CONFIRM:
|
||||
return <Button fullWidth variant="warning" onClick={ purchaseSubscription }>{ LocalizeText('catalog.marketplace.confirm_title') }</Button>;
|
||||
return <Button disabled={ giftBlocked } fullWidth variant="warning" onClick={ purchaseSubscription }>{ LocalizeText('catalog.marketplace.confirm_title') }</Button>;
|
||||
case CatalogPurchaseState.PURCHASE:
|
||||
return <Button disabled fullWidth variant="primary"><LayoutLoadingSpinnerView /></Button>;
|
||||
case CatalogPurchaseState.FAILED:
|
||||
return <Button disabled fullWidth variant="danger">{ LocalizeText('generic.failed') }</Button>;
|
||||
case CatalogPurchaseState.NONE:
|
||||
default:
|
||||
return <Button fullWidth variant="success" onClick={ () => setPurchaseState(CatalogPurchaseState.CONFIRM) }>{ LocalizeText('buy') }</Button>;
|
||||
return <Button disabled={ giftBlocked } fullWidth variant="success" onClick={ () => setPurchaseState(CatalogPurchaseState.CONFIRM) }>{ buyLabel }</Button>;
|
||||
}
|
||||
}, [ pendingOffer, purchaseState, purchaseSubscription, getCurrencyAmount ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!offers) SendMessageComposer(new GetClubOffersMessageComposer(1));
|
||||
}, [ offers ]);
|
||||
}, [ pendingOffer, purchaseState, purchaseSubscription, getCurrencyAmount, giftMode, giftRecipient, isSelfGift ]);
|
||||
|
||||
return (
|
||||
<Grid>
|
||||
@@ -139,25 +196,29 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
|
||||
<AutoGrid className="nitro-catalog-layout-vip-buy-grid" columnCount={ 1 }>
|
||||
{ offers && (offers.length > 0) && offers.map((offer, index) =>
|
||||
{
|
||||
const isActive = (pendingOffer === offer);
|
||||
|
||||
return (
|
||||
<LayoutGridItem key={ index } alignItems="center" center={ false } className="p-1" column={ false } itemActive={ pendingOffer === offer } justifyContent="between" onClick={ () => setOffer(offer) }>
|
||||
<i className="icon-hc-banner" />
|
||||
<Column gap={ 0 } justifyContent="end">
|
||||
<Text textEnd>{ getOfferText(offer) }</Text>
|
||||
<Flex gap={ 1 } justifyContent="end">
|
||||
{ (offer.priceCredits > 0) &&
|
||||
<Flex alignItems="center" gap={ 1 } justifyContent="end">
|
||||
<Text>{ offer.priceCredits }</Text>
|
||||
<LayoutCurrencyIcon type={ -1 } />
|
||||
</Flex> }
|
||||
{ (offer.priceActivityPoints > 0) &&
|
||||
<Flex alignItems="center" gap={ 1 } justifyContent="end">
|
||||
<Text>{ offer.priceActivityPoints }</Text>
|
||||
<LayoutCurrencyIcon type={ offer.priceActivityPointsType } />
|
||||
</Flex> }
|
||||
</Flex>
|
||||
</Column>
|
||||
</LayoutGridItem>
|
||||
<div key={ index } className={ 'nitro-vip-buy-offer flex flex-col gap-1.5 p-2 rounded-md border-2 cursor-pointer ' + (isActive ? 'active border-[#7a5500] bg-[#ffe066]' : 'border-[#b48a18] bg-[#fffbe7] hover:bg-[#fff5c4] hover:border-[#9c7610]') } onClick={ () => setOffer(offer) }>
|
||||
<div className="vip-offer-header flex items-center gap-2 pb-1.5 border-b border-dashed border-[#b48a18]">
|
||||
<span className="vip-offer-banner inline-flex items-center justify-center shrink-0 w-[34px] h-[20px]">
|
||||
<i className="nitro-icon icon-hc-banner" style={ { width: '34px', height: '20px', backgroundSize: 'contain', backgroundRepeat: 'no-repeat', backgroundPosition: 'center' } } />
|
||||
</span>
|
||||
<span className="vip-offer-title flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-bold text-[1.05rem] leading-tight text-[#2c2a25]">{ getOfferText(offer) }</span>
|
||||
</div>
|
||||
<div className="vip-offer-prices flex flex-col gap-1">
|
||||
{ (offer.priceCredits > 0) &&
|
||||
<span className="vip-offer-price flex items-center gap-1.5 font-bold text-[0.95rem] leading-tight text-[#4a473e] whitespace-nowrap">
|
||||
<LayoutCurrencyIcon type={ -1 } />
|
||||
<span>{ offer.priceCredits }</span>
|
||||
</span> }
|
||||
{ (offer.priceActivityPoints > 0) &&
|
||||
<span className="vip-offer-price flex items-center gap-1.5 font-bold text-[0.95rem] leading-tight text-[#4a473e] whitespace-nowrap">
|
||||
<LayoutCurrencyIcon type={ offer.priceActivityPointsType } />
|
||||
<span>{ offer.priceActivityPoints }</span>
|
||||
</span> }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}) }
|
||||
</AutoGrid>
|
||||
@@ -172,7 +233,7 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
|
||||
<Column fullWidth grow justifyContent="end">
|
||||
<Flex alignItems="end">
|
||||
<Column grow gap={ 0 }>
|
||||
<Text fontWeight="bold">{ getPurchaseHeader() }</Text>
|
||||
<Text fontWeight="bold">{ giftMode ? LocalizeText('catalog.purchase_confirmation.gift') : getPurchaseHeader() }</Text>
|
||||
<Text>{ getPurchaseValidUntil() }</Text>
|
||||
</Column>
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -188,6 +249,28 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
|
||||
</Flex> }
|
||||
</div>
|
||||
</Flex>
|
||||
{ pendingOffer.giftable &&
|
||||
<Column className="mt-1" gap={ 1 }>
|
||||
<Flex alignItems="center" gap={ 2 }>
|
||||
<label className="flex items-center gap-1 cursor-pointer text-sm">
|
||||
<input checked={ giftMode } className="cursor-pointer" type="checkbox" onChange={ event => { setGiftMode(event.target.checked); setGiftError(null); setGiftSuccess(false); } } />
|
||||
<span>{ LocalizeText('catalog.purchase_confirmation.gift') }</span>
|
||||
</label>
|
||||
{ giftMode &&
|
||||
<input
|
||||
className="flex-1 min-w-0 border border-[#b48a18] bg-white rounded px-2 py-1 text-sm"
|
||||
placeholder={ LocalizeText('catalog.gift_wrapping.receiver') }
|
||||
type="text"
|
||||
value={ giftRecipient }
|
||||
onChange={ event => onGiftRecipientChange(event.target.value) } /> }
|
||||
</Flex>
|
||||
{ giftMode && isSelfGift &&
|
||||
<Text className="text-[#b00020] text-xs">{ LocalizeText('catalog.gift_wrapping.cannot_send_to_self') }</Text> }
|
||||
{ giftMode && giftError && !isSelfGift &&
|
||||
<Text className="text-[#b00020] text-xs">{ giftError }</Text> }
|
||||
{ giftSuccess &&
|
||||
<Text className="text-[#1f7a1f] text-sm font-bold">{ LocalizeText('catalog.gift_wrapping.gift_sent') }</Text> }
|
||||
</Column> }
|
||||
{ getPurchaseButton() }
|
||||
</Column> }
|
||||
</Column>
|
||||
|
||||
@@ -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 } /> }
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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 }>‹</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 }>›</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 3–16 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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 3–16 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) }>‹</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) }>›</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) }>‹</button>
|
||||
<div className="color-swatch" style={ { background: swatchHex } } />
|
||||
<button type="button" className="arrow-btn" aria-label={ `Next color ${ setType }` }
|
||||
onClick={ () => cycleColor(setType, 1) }>›</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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,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 '';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user