CatalogView.tsx 18.5 Ko
Télécharger le fichier
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@octane/renderer';
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FaBars, FaCog } from 'react-icons/fa';
import { CatalogType, GetConfigurationValue, LocalizeShortNumber, LocalizeText, localizeWithFallback, SanitizeHtml } from '../../api';
import { LayoutCurrencyIcon, OctaneCardContentView, OctaneCardHeaderView, OctaneCardTabsItemView, OctaneCardTabsView, OctaneCardView } from '../../common';
import { BaseButton } from '../../common/BaseButton';
import { CssNativeText } from '../../common/native-text/CssNativeText';
import { ToggleButton } from '../../common/ToggleButton';
import { CatalogEffectsHost, useCatalogActions, useCatalogData, useCatalogUiState, useHasPermission, usePurse } from '../../hooks';
import { CatalogStudioProvider } from './admin/studio/CatalogStudioProvider';
import { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext';
import { getCatalogHeaderDescription } from './catalogLocalization.helpers';
import { parseCatalogTabLabel, useCatalogWindowWidth } from './useCatalogWindowWidth';
import { CatalogAdminManagerView } from './views/admin/CatalogAdminManagerView';
import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView';
import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView';
import { CatalogLoadingStateView } from './views/CatalogLoadingStateView';
import { CatalogBuildersClubStatusView } from './views/catalog-header/CatalogBuildersClubStatusView';
import { CatalogIconView } from './views/catalog-icon/CatalogIconView';
import { CatalogGiftView } from './views/gift/CatalogGiftView';
import { CatalogBreadcrumbView } from './views/navigation/CatalogBreadcrumbView';
import { CatalogNavigationView } from './views/navigation/CatalogNavigationView';
import { CatalogSearchView } from './views/page/common/CatalogSearchView';
import { GetCatalogLayout } from './views/page/layout/GetCatalogLayout';
import { MarketplacePostOfferView } from './views/page/layout/marketplace/MarketplacePostOfferView';
import { VipBenefitsView } from './views/vip-benefits/VipBenefitsView';
const CatalogViewInner: FC<{}> = () => {
const { rootNode = null, currentPage = null, searchResult = null, isBusy = false, catalogLoadError = null } = useCatalogData();
const {
isVisible = false,
setIsVisible = null,
navigationHidden = false,
setNavigationHidden = null,
activeNodes = [],
setSearchResult = null,
currentType = CatalogType.NORMAL
} = useCatalogUiState();
const {
openPageById = null,
openPageByName = null,
openPageByOfferId = null,
activateNode = null,
openCatalogByType = null,
toggleCatalogByType = null,
retryCurrentPage = null
} = useCatalogActions();
const catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
const setAdminMode = catalogAdmin?.setAdminMode ?? (() => {});
// One function for the whole session: a layout that hides the navigation must not hide it again on every render.
const hideNavigation = useCallback(() => setNavigationHidden?.(true), [setNavigationHidden]);
const isMod = useHasPermission('acc_catalogfurni');
// AIR's Builders Club warehouse has its own title and no tab row: its pages are listed on the left only.
const isBuilder = currentType === CatalogType.BUILDER;
const adminToggle = <FaCog className={`text-[10px] ${adminMode ? 'animate-spin' : ''}`} style={adminMode ? { animationDuration: '3s' } : {}} />;
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const { purse = null } = usePurse();
const displayedCurrencies = GetConfigurationValue<number[]>('system.currency.types', []);
const activeCatalogNode = activeNodes?.[activeNodes.length - 1] ?? null;
const buildersClubEnabled = GetConfigurationValue<boolean>('buildersclub.enabled', GetConfigurationValue<boolean>('toolbar.buildersclub.enabled', true));
// Strip technical suffixes like "(BC)" or "(Hot)" but keep the
// pageId hint the gameserver appends when the viewer has
// ACC_CATALOG_IDS - that's a pure-numeric "(6)" trailer.
const stripSwfTabSuffix = (label: string) => (label || '').replace(/\s*\(\D[^)]*\)\s*$/g, '').trim();
const getSwfTabLabel = (label: string) => stripSwfTabSuffix(parseCatalogTabLabel(label).name);
const tabsShellRef = useRef<HTMLDivElement>(null);
const visibleRootTabCount = useMemo(() => {
if (!rootNode?.children?.length) return 0;
return rootNode.children.filter((child) => {
if (!child.isVisible) return false;
return true;
}).length;
}, [rootNode]);
const catalogWindowStyle = useCatalogWindowWidth(
tabsShellRef,
isVisible,
visibleRootTabCount,
adminMode,
isMod,
currentType,
rootNode?.pageId,
activeCatalogNode?.pageId
);
useEffect(() => {
const getCatalogTypeFromLink = (type?: string) => {
switch ((type || '').toLowerCase()) {
case 'bc':
case 'builder':
case 'buildersclub':
case 'builders_club':
return buildersClubEnabled ? CatalogType.BUILDER : CatalogType.NORMAL;
default:
return CatalogType.NORMAL;
}
};
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) => {
const parts = url.split('/');
if (parts.length < 2) return;
switch (parts[1]) {
case 'show':
if (parts.length > 2) {
openCatalogByType(getCatalogTypeFromLink(parts[2]));
return;
}
setIsVisible(true);
return;
case 'hide':
setIsVisible(false);
return;
case 'toggle':
if (parts.length > 2) {
toggleCatalogByType(getCatalogTypeFromLink(parts[2]));
return;
}
setIsVisible((prevValue) => !prevValue);
return;
case 'open':
if (parts.length > 2) {
if (parts.length === 4 && parts[2] === 'offerId') {
openPageByOfferId(parseInt(parts[3]));
} else if (parts.length > 3) {
// A page path: catalog/open/clothing/effects.
openPageByName(parts.slice(2).join('/'));
} else {
const pageId = Number(parts[2]);
if (Number.isInteger(pageId) && pageId > 0) openPageById(pageId);
else openPageByName(parts[2]);
}
} else {
setIsVisible(true);
}
return;
}
},
eventUrlPrefix: 'catalog/'
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, [setIsVisible, openPageById, openPageByOfferId, openPageByName, openCatalogByType, toggleCatalogByType, buildersClubEnabled]);
return (
<>
{isVisible && (
<OctaneCardView
classNames={['octane-catalog-window']}
dragStyle={catalogWindowStyle}
isResizable={false}
style={catalogWindowStyle}
uniqueKey="catalog"
>
<OctaneCardHeaderView
className={isBuilder ? 'builders-club-card-header' : ''}
headerText={
isBusy
? LocalizeText('generic.loading') || 'Loading...'
: isBuilder
? localizeWithFallback('builder.catalog.title', 'The Builders Club Warehouse')
: LocalizeText('catalog.title')
}
onCloseClick={() => setIsVisible(false)}
/>
<div className="octane-catalog-mobile-header">
{isMod && (
<div className="octane-catalog-mobile-burger">
<BaseButton className="octane-catalog-burger-btn" onClick={() => setMobileMenuOpen((value) => !value)}>
<FaBars />
</BaseButton>
{mobileMenuOpen && (
<div className="octane-catalog-burger-menu">
<BaseButton
onClick={() => {
setAdminMode(!adminMode);
setMobileMenuOpen(false);
}}
>
{adminMode ? 'Exit Admin' : 'Admin'}
</BaseButton>
</div>
)}
</div>
)}
<div className="octane-catalog-mobile-currency">
<div className="octane-catalog-coin">
<span>{LocalizeShortNumber(purse?.credits ?? 0)}</span>
<LayoutCurrencyIcon type={-1} />
</div>
{displayedCurrencies.map((type) => (
<div key={type} className="octane-catalog-coin">
<span>{LocalizeShortNumber(purse?.activityPoints?.get(type) ?? 0)}</span>
<LayoutCurrencyIcon type={type} />
</div>
))}
</div>
</div>
{!isBuilder && (
<OctaneCardTabsView classNames={['octane-catalog-tabs-shell']} innerRef={tabsShellRef} justifyContent="start">
{rootNode &&
rootNode.children.length > 0 &&
rootNode.children.map((child, index) => {
if (!child.isVisible) return null;
return (
<OctaneCardTabsItemView
key={`${child.pageId}-${child.pageName}-${index}`}
isActive={child.isActive}
title={child.localization}
onClick={() => {
if (searchResult) setSearchResult(null);
activateNode(child);
}}
>
<div className="flex items-center gap-1">
<span className="octane-catalog-tab-label">
<CssNativeText
colorKey={child.isActive}
text={getSwfTabLabel(child.localization)}
textStyle="u_button_tab"
/>
</span>
</div>
</OctaneCardTabsItemView>
);
})}
{isMod && (
<OctaneCardTabsItemView classNames={['octane-catalog-admin-tab']} isActive={adminMode} onClick={() => setAdminMode(!adminMode)}>
{adminToggle}
</OctaneCardTabsItemView>
)}
</OctaneCardTabsView>
)}
<div className={`octane-catalog-standard-header ${isBuilder ? 'is-builder' : ''}`}>
{isBuilder && isMod && (
<ToggleButton aria-label="Admin" className="octane-catalog-builder-admin-toggle" pressed={adminMode} onPressedChange={setAdminMode}>
{adminToggle}
</ToggleButton>
)}
<div
className="octane-catalog-standard-header-bg"
style={currentPage?.localization?.getImage(0) ? { backgroundImage: `url(${currentPage.localization.getImage(0)})` } : undefined}
/>
<div className="octane-catalog-standard-header-icon">
<CatalogIconView icon={activeCatalogNode?.iconId ?? rootNode?.iconId ?? 1} />
</div>
<div className="octane-catalog-standard-header-copy">
{isBuilder ? (
<CatalogBuildersClubStatusView />
) : (
<>
<div className="octane-catalog-standard-header-title">
<CssNativeText
text={
searchResult
? LocalizeText('catalog.search.header')
: getSwfTabLabel(activeCatalogNode?.localization ?? LocalizeText('catalog.title'))
}
textStyle="u_headline_big"
/>
</div>
<div
className="octane-catalog-standard-header-description"
dangerouslySetInnerHTML={{
__html: SanitizeHtml(
searchResult
? LocalizeText(
'catalog.search.results',
['count', 'needle'],
[String(searchResult.offers.length), searchResult.searchValue]
)
: getCatalogHeaderDescription(currentPage?.layoutCode, currentPage?.localization)
)
}}
/>
</>
)}
</div>
</div>
<OctaneCardContentView classNames={['octane-catalog-content-shell']}>
<div className={`octane-catalog-stage ${navigationHidden ? 'is-navigation-hidden' : ''}`}>
{!navigationHidden && (
<div className="octane-catalog-sidebar">
<div className="octane-catalog-search-shell">
<CatalogSearchView />
</div>
<div className="octane-catalog-navigation-shell">
{activeNodes && activeNodes.length > 0 && <CatalogNavigationView node={activeNodes[0]} catalogType={currentType} />}
</div>
</div>
)}
<div aria-busy={isBusy} className="octane-catalog-layout-shell">
<div className="octane-catalog-layout-header-shell">
<CatalogBreadcrumbView />
<div className="octane-catalog-layout-hero">
{!!currentPage?.localization?.getImage(0) && <img alt="" src={currentPage.localization.getImage(0)} />}
</div>
</div>
<div className="octane-catalog-layout-container">{GetCatalogLayout(currentPage, hideNavigation)}</div>
</div>
</div>
</OctaneCardContentView>
{(isBusy || catalogLoadError) && <CatalogLoadingStateView error={catalogLoadError} onRetry={retryCurrentPage} />}
</OctaneCardView>
)}
<CatalogAdminManagerView />
<CatalogAdminPageEditView />
<CatalogAdminOfferEditView />
<CatalogGiftView />
<MarketplacePostOfferView />
<VipBenefitsView />
</>
);
};
export const CatalogView: FC<{}> = () => {
const { catalogLocalizationVersion = 0 } = useCatalogData();
const { isVisible = false } = useCatalogUiState();
const isCatalogAdmin = useHasPermission('acc_catalogfurni');
// Opening a studio session is expensive on the server: it loads every
// catalog item with FOR UPDATE and every items_base id, on the thread
// serving this client. Tying it to authentication meant every staff login
// stalled the hotel view — no room list, no catalog — until it finished.
// The session is only needed once the catalog is actually open.
return (
<CatalogStudioProvider active={isCatalogAdmin && isVisible}>
<CatalogEffectsHost />
<CatalogAdminProvider>
<div className="hidden" data-catalog-localization-version={catalogLocalizationVersion} />
<CatalogViewInner />
</CatalogAdminProvider>
</CatalogStudioProvider>
);
};
