Take #2 Desktop cacta 100%

This commit is contained in:
duckietm
2026-06-05 14:32:55 +02:00
parent 5c282101ee
commit f4d41dd3c9
81 changed files with 2898 additions and 1449 deletions
+73 -3
View File
@@ -1,4 +1,4 @@
import { CatalogAdminCreateOfferComposer, CatalogAdminCreatePageComposer, CatalogAdminDeleteOfferComposer, CatalogAdminDeletePageComposer, CatalogAdminMoveOfferComposer, CatalogAdminMovePageComposer, CatalogAdminPublishComposer, CatalogAdminResultEvent, CatalogAdminSaveOfferComposer, CatalogAdminSavePageComposer } from '@nitrots/nitro-renderer';
import { CatalogAdminCreateOfferComposer, CatalogAdminCreatePageComposer, CatalogAdminDeleteOfferComposer, CatalogAdminDeletePageComposer, CatalogAdminLoadOfferComposer, CatalogAdminLoadPageComposer, CatalogAdminMoveOfferComposer, CatalogAdminMovePageComposer, CatalogAdminOfferDetailsEvent, CatalogAdminPageDetailsEvent, 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 { useCatalogUiState, useMessageEvent, useNotification } from '../../hooks';
@@ -44,12 +44,34 @@ export interface IOfferEditData
orderNumber: number;
}
export interface IEditingOfferDetails
{
offerId: number;
offerIdGroup: number;
limitedStack: number;
orderNumber: number;
}
export interface IEditingPageDetails
{
pageId: number;
caption: string;
captionSave: string;
minRank: number;
orderNum: number;
visible: boolean;
enabled: boolean;
}
interface ICatalogAdminContext
{
adminMode: boolean;
setAdminMode: (value: boolean) => void;
editingOffer: IPurchasableOffer | null;
setEditingOffer: (offer: IPurchasableOffer | null) => void;
editingOfferDetails: IEditingOfferDetails | null;
editingPageDetails: IEditingPageDetails | null;
requestPageDetails: (pageId: number) => void;
editingPageData: boolean;
setEditingPageData: (value: boolean) => void;
editingRootPage: boolean;
@@ -80,7 +102,9 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children })
{
const { currentType } = useCatalogUiState();
const [ adminMode, setAdminMode ] = useState(false);
const [ editingOffer, setEditingOffer ] = useState<IPurchasableOffer | null>(null);
const [ editingOffer, setEditingOfferState ] = useState<IPurchasableOffer | null>(null);
const [ editingOfferDetails, setEditingOfferDetails ] = useState<IEditingOfferDetails | null>(null);
const [ editingPageDetails, setEditingPageDetails ] = useState<IEditingPageDetails | null>(null);
const [ editingPageData, setEditingPageData ] = useState(false);
const [ editingRootPage, setEditingRootPage ] = useState(false);
const [ editingPageNode, setEditingPageNode ] = useState<ICatalogNode | null>(null);
@@ -90,6 +114,51 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children })
const pendingActionRef = useRef<string | null>(null);
const { simpleAlert = null } = useNotification();
const setEditingOffer = useCallback((offer: IPurchasableOffer | null) =>
{
setEditingOfferState(offer);
setEditingOfferDetails(null);
if(offer && offer.offerId !== -1)
{
SendMessageComposer(new CatalogAdminLoadOfferComposer(offer.offerId, currentType));
}
}, [ currentType ]);
useMessageEvent(CatalogAdminOfferDetailsEvent, (event: CatalogAdminOfferDetailsEvent) =>
{
const parser = event.getParser();
setEditingOfferDetails({
offerId: parser.offerId,
offerIdGroup: parser.offerIdGroup,
limitedStack: parser.limitedStack,
orderNumber: parser.orderNumber
});
});
useMessageEvent(CatalogAdminPageDetailsEvent, (event: CatalogAdminPageDetailsEvent) =>
{
const parser = event.getParser();
setEditingPageDetails({
pageId: parser.pageId,
caption: parser.caption,
captionSave: parser.captionSave,
minRank: parser.minRank,
orderNum: parser.orderNum,
visible: parser.visible,
enabled: parser.enabled
});
});
const requestPageDetails = useCallback((pageId: number) =>
{
setEditingPageDetails(null);
if(pageId == null || pageId < 0) return;
SendMessageComposer(new CatalogAdminLoadPageComposer(pageId, currentType));
}, [ currentType ]);
useEffect(() =>
{
if(!adminMode) return;
@@ -288,7 +357,8 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children })
return (
<CatalogAdminContext value={ {
adminMode, setAdminMode,
editingOffer, setEditingOffer,
editingOffer, setEditingOffer, editingOfferDetails,
editingPageDetails, requestPageDetails,
editingPageData, setEditingPageData,
editingRootPage, setEditingRootPage,
editingPageNode, setEditingPageNode,
+35 -17
View File
@@ -1,7 +1,7 @@
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { FaBars, FaCog, FaEdit, FaEye, FaEyeSlash, FaPlus, FaTrash } from 'react-icons/fa';
import { CatalogType, GetConfigurationValue, LocalizeShortNumber, LocalizeText } from '../../api';
import { CatalogType, GetConfigurationValue, LocalizeShortNumber, LocalizeText, SanitizeHtml } from '../../api';
import { Column, Grid, LayoutCurrencyIcon, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common';
import { useCatalogActions, useCatalogData, useCatalogUiState, useHasPermission, usePurse } from '../../hooks';
import { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext';
@@ -18,7 +18,7 @@ import { MarketplacePostOfferView } from './views/page/layout/marketplace/Market
const CatalogClassicViewInner: FC<{}> = () =>
{
const { rootNode = null, currentPage = null, searchResult = null } = useCatalogData();
const { rootNode = null, currentPage = null, currentOffer = 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();
@@ -34,6 +34,11 @@ const CatalogClassicViewInner: FC<{}> = () =>
const [ mobileMenuOpen, setMobileMenuOpen ] = useState(false);
const { purse = null } = usePurse();
const displayedCurrencies = GetConfigurationValue<number[]>('system.currency.types', []);
const activeCatalogNode = activeNodes?.[activeNodes.length - 1] ?? null;
// Strip SWF-style 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 getSwfTabLabel = (label: string) => (label || '').replace(/\s*\(\D[^)]*\)\s*$/g, '').trim();
const buildersClubHeaderStyle = (currentType === CatalogType.BUILDER)
? { borderColor: '#d79d2e', borderBottomColor: '#000', background: 'linear-gradient(180deg, #d89f2d 0%, #c68515 100%)' }
: undefined;
@@ -122,7 +127,7 @@ const CatalogClassicViewInner: FC<{}> = () =>
return (
<>
{ isVisible &&
<NitroCardView classNames={ [ 'nitro-catalog-classic-window' ] } isResizable={ false } uniqueKey="catalog">
<NitroCardView classNames={ [ 'habbo-swf-window', 'habbo-swf-catalog-window', 'nitro-catalog-classic-window' ] } isResizable={ false } uniqueKey="catalog">
<NitroCardHeaderView className={ currentType === CatalogType.BUILDER ? 'builders-club-card-header' : '' } headerText={ LocalizeText('catalog.title') } onCloseClick={ () => setIsVisible(false) } style={ buildersClubHeaderStyle } />
<div className="nitro-catalog-classic-mobile-header">
{ isMod &&
@@ -161,20 +166,19 @@ const CatalogClassicViewInner: FC<{}> = () =>
</div>
</div>
{ adminMode &&
<div className="nitro-catalog-classic-admin-banner flex items-center justify-between text-[10px] font-bold px-3 py-0.5 uppercase tracking-wider">
<span>Admin Mode</span>
<button
className={ `px-3 py-0.5 rounded text-[10px] font-bold uppercase cursor-pointer transition-all ${ hasPendingChanges ? 'bg-success text-white animate-pulse shadow-md' : 'bg-white/50 text-dark hover:bg-success hover:text-white' }` }
disabled={ loading }
onClick={ () => publishCatalog() }
>
{ loading ? '...' : 'Publish' }
</button>
</div> }
<button
className={ `nitro-catalog-classic-header-publish nitro-catalog-swf-button nitro-catalog-swf-buy-button ${ hasPendingChanges ? 'has-pending' : '' }` }
disabled={ loading }
onClick={ () => publishCatalog() }
title={ hasPendingChanges ? 'You have unsaved changes - click to publish' : 'Publish catalog' }
>
{ loading ? '...' : 'PUBLISH' }
</button> }
<NitroCardTabsView classNames={ [ 'nitro-catalog-classic-tabs-shell' ] } justifyContent="start">
{ rootNode && (rootNode.children.length > 0) && rootNode.children.map((child, index) =>
{
if(!adminMode && !child.isVisible) return null;
if(!adminMode && (index === 0) && getSwfTabLabel(child.localization).toLowerCase().includes('rari')) return null;
const isHidden = !child.isVisible;
@@ -186,8 +190,9 @@ const CatalogClassicViewInner: FC<{}> = () =>
activateNode(child);
} }>
<div className={ `flex items-center gap-1 ${ isHidden ? 'opacity-40' : '' }` }>
<CatalogIconView icon={ child.iconId } />
<span className="nitro-catalog-classic-tab-label truncate">{ child.localization }</span>
{ (child.iconId > 0) &&
<CatalogIconView icon={ child.iconId } className="nitro-catalog-classic-tab-icon" /> }
<span className="nitro-catalog-classic-tab-label truncate">{ getSwfTabLabel(child.localization) }</span>
{ adminMode && isHidden && <FaEyeSlash className="text-[8px] text-danger ml-1" /> }
{ adminMode &&
<div className="flex items-center gap-0.5 ml-1" onClick={ e => e.stopPropagation() }>
@@ -215,6 +220,20 @@ const CatalogClassicViewInner: FC<{}> = () =>
<FaCog className={ `text-[10px] ${ adminMode ? 'animate-spin' : '' }` } style={ adminMode ? { animationDuration: '3s' } : {} } />
</NitroCardTabsItemView> }
</NitroCardTabsView>
<div className="nitro-catalog-classic-swf-header">
<div className="nitro-catalog-classic-swf-header-bg" style={ currentPage?.localization?.getImage(0) ? { backgroundImage: `url(${ currentPage.localization.getImage(0) })` } : undefined } />
<div className="nitro-catalog-classic-swf-header-icon">
<CatalogIconView icon={ activeCatalogNode?.iconId ?? rootNode?.iconId ?? 1 } />
</div>
<div className="nitro-catalog-classic-swf-header-copy">
<div className="nitro-catalog-classic-swf-header-title">
{ currentType === CatalogType.BUILDER ? LocalizeText('builder.header.title') : getSwfTabLabel(activeCatalogNode?.localization ?? LocalizeText('catalog.title')) }
</div>
{ currentType === CatalogType.BUILDER
? <div className="nitro-catalog-classic-swf-header-description">{ LocalizeText('builder.header.status.membership') }</div>
: <div className="nitro-catalog-classic-swf-header-description" dangerouslySetInnerHTML={ { __html: SanitizeHtml(currentPage?.localization?.getText(0) || '') } } /> }
</div>
</div>
<NitroCardContentView classNames={ [ 'nitro-catalog-classic-content-shell' ] }>
<CatalogBuildersClubStatusView />
{ adminMode && rootNode &&
@@ -252,8 +271,7 @@ const CatalogClassicViewInner: FC<{}> = () =>
<div className="nitro-catalog-classic-layout-header-shell">
<CatalogBreadcrumbView />
<div className="nitro-catalog-classic-layout-hero">
{ /* info_duckets renders its own logo in the body (BcInfoView) — don't duplicate it in the hero */ }
{ (currentPage?.layoutCode !== 'info_duckets') && !!currentPage?.localization?.getImage(0) && <img src={ currentPage.localization.getImage(0) } /> }
{ !!currentPage?.localization?.getImage(0) && <img src={ currentPage.localization.getImage(0) } /> }
</div>
</div>
<div className="nitro-catalog-classic-layout-container">
@@ -10,6 +10,7 @@ export const CatalogAdminOfferEditView: FC<{}> = () =>
const { currentPage = null } = useCatalogData();
const catalogAdmin = useCatalogAdmin();
const editingOffer = catalogAdmin?.editingOffer ?? null;
const editingOfferDetails = catalogAdmin?.editingOfferDetails ?? null;
const setEditingOffer = catalogAdmin?.setEditingOffer;
const saveOffer = catalogAdmin?.saveOffer;
const deleteOffer = catalogAdmin?.deleteOffer;
@@ -62,12 +63,21 @@ export const CatalogAdminOfferEditView: FC<{}> = () =>
setClubOnly(editingOffer.clubLevel > 0 ? '1' : '0');
setExtradata(editingOffer.product?.extraParam || '');
setHaveOffer(editingOffer.haveOffer ? '1' : '0');
setOfferIdGroup(editingOffer.offerId || -1);
setOfferIdGroup(0);
setLimitedStack(0);
setOrderNumber(0);
}
}, [ editingOffer ]);
useEffect(() =>
{
if(!editingOfferDetails) return;
setOfferIdGroup(editingOfferDetails.offerIdGroup);
setLimitedStack(editingOfferDetails.limitedStack);
setOrderNumber(editingOfferDetails.orderNumber);
}, [ editingOfferDetails ]);
if(!editingOffer) return null;
const handleSave = async () =>
@@ -28,6 +28,8 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
const editingPageData = catalogAdmin?.editingPageData ?? false;
const editingRootPage = catalogAdmin?.editingRootPage ?? false;
const editingPageNode = catalogAdmin?.editingPageNode ?? null;
const editingPageDetails = catalogAdmin?.editingPageDetails ?? null;
const requestPageDetails = catalogAdmin?.requestPageDetails;
const loading = catalogAdmin?.loading ?? false;
const [ caption, setCaption ] = useState('');
@@ -67,21 +69,22 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
{
if(!editingPageData || !targetNode) return;
// The server appends " (pageId)" to the caption for mods/admins (see
// CatalogPagesListComposer). Strip that exact suffix before seeding the
// edit field, otherwise saving folds the id back into the stored
// caption and it multiplies on every edit ("Wired (1114) (1114) ...").
const rawCaption = (targetNode.localization || '').replace(new RegExp(`\\s*\\(${ targetNode.pageId }\\)\\s*$`), '');
// Don't read the decorated caption out of the catalog index -
// the gameserver appends " (id)" when ACC_CATALOG_IDS is on and
// we don't want that round-tripping back into the DB. Wait for
// the admin page-details event to land instead; it carries the
// raw caption / caption_save / min_rank / order_num / enabled.
setCaption('');
setCaptionSave('');
setMinRank(1);
setOrderNum(0);
setEnabled('1');
setCaption(rawCaption);
setCaptionSave(targetNode.pageName || rawCaption);
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)
@@ -94,7 +97,22 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
setParentId(typeof wireParentId === 'number' && wireParentId !== -1
? wireParentId
: (targetNode.parent ? targetNode.parent.pageId : -1));
}, [ editingPageData, targetNode, currentPage, currentType ]);
if(targetPageId != null && targetPageId >= 0) requestPageDetails?.(targetPageId);
}, [ editingPageData, targetNode, currentPage, currentType, targetPageId, requestPageDetails ]);
useEffect(() =>
{
if(!editingPageDetails) return;
if(targetPageId != null && editingPageDetails.pageId !== targetPageId) return;
setCaption(editingPageDetails.caption);
setCaptionSave(editingPageDetails.captionSave);
setMinRank(editingPageDetails.minRank);
setOrderNum(editingPageDetails.orderNum);
setVisible(editingPageDetails.visible ? '1' : '0');
setEnabled(editingPageDetails.enabled ? '1' : '0');
}, [ editingPageDetails, targetPageId ]);
if(!editingPageData || !targetNode) return null;
@@ -168,7 +186,7 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
const handleDelete = async () =>
{
if(!catalogAdmin?.deletePage || isRoot) return;
if(!confirm(LocalizeText('catalog.admin.delete.page.confirm', [ 'name' ], [ targetNode.localization ]))) return;
if(!confirm(LocalizeText('catalog.admin.delete.page.confirm', [ 'name' ], [ editingPageDetails?.caption ?? '' ]))) return;
catalogAdmin.deletePage(targetPageId);
@@ -179,7 +197,7 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
<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 }` }
{ isRoot ? LocalizeText('catalog.admin.edit.root') : `${ LocalizeText('catalog.admin.edit') } ${ editingPageDetails?.caption ?? '' }` }
</span>
<FaTimes className="text-muted cursor-pointer hover:text-danger text-[10px]" onClick={ closeForm } />
</div>
@@ -23,6 +23,10 @@ export const CatalogNavigationItemView: FC<CatalogNavigationItemViewProps> = pro
const isFav = node ? isFavoritePage(node.pageId) : false;
const [ isDragOver, setIsDragOver ] = useState(false);
const dragRef = useRef<HTMLDivElement>(null);
// Strip SWF-style 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 swfLabel = (node?.localization || '').replace(/\s*\(\D[^)]*\)\s*$/g, '').trim();
const handleDragStart = useCallback((e: React.DragEvent) =>
{
@@ -90,7 +94,7 @@ export const CatalogNavigationItemView: FC<CatalogNavigationItemViewProps> = pro
<div className="nitro-catalog-classic-navigation-icon">
<CatalogIconView icon={ node.iconId } />
</div>
<span className="nitro-catalog-classic-navigation-label" title={ adminMode ? `Page ID: ${ node.pageId }` : undefined }>{ node.localization }</span>
<span className="nitro-catalog-classic-navigation-label" title={ adminMode ? `Page ID: ${ node.pageId }` : undefined }>{ swfLabel }</span>
{ adminMode &&
<div className="nitro-catalog-classic-navigation-admin flex items-center gap-1 opacity-0 group-hover/nav:opacity-100 transition-opacity">
<FaPlus
@@ -1,7 +1,7 @@
import { MouseEventType } from '@nitrots/nitro-renderer';
import { FC, MouseEvent, useMemo, useState } from 'react';
import { FaHeart } from 'react-icons/fa';
import { CatalogType, IPurchasableOffer, Offer, ProductTypeEnum } from '../../../../../api';
import { CatalogType, GetConfigurationValue, IPurchasableOffer, Offer, ProductTypeEnum } from '../../../../../api';
import { LayoutAvatarImageView, LayoutGridItem, LayoutGridItemProps } from '../../../../../common';
import { useCatalogActions, useCatalogFavorites, useCatalogUiState, useInventoryFurni } from '../../../../../hooks';
@@ -30,9 +30,50 @@ export const CatalogGridOfferView: FC<CatalogGridOfferViewProps> = props =>
return null;
}
return offer.product?.getIconUrl(offer) ?? null;
const product = offer.product;
if(!product) return null;
if((product.productType === ProductTypeEnum.FLOOR) || (product.productType === ProductTypeEnum.WALL))
{
const className = product.furnitureData?.className;
if(className?.length)
{
const param = (product.productType === ProductTypeEnum.WALL && product.extraParam?.length) ? `_${ product.extraParam }` : '';
const configuredIconUrl = GetConfigurationValue<string>('furni.asset.icon.url', '');
if(configuredIconUrl?.length)
{
return configuredIconUrl
.replace('%libname%', className)
.replace('%param%', param);
}
}
}
return product.getIconUrl(offer) ?? null;
}, [ offer ]);
const prices = useMemo(() =>
{
if(!offer) return [];
const values: { amount: number; type: number }[] = [];
if(offer.priceInCredits > 0) values.push({ amount: offer.priceInCredits, type: -1 });
if(offer.priceInActivityPoints > 0) values.push({ amount: offer.priceInActivityPoints, type: offer.activityPointType });
return values;
}, [ offer ]);
const getCurrencyIconUrl = (type: number) =>
{
const configuredCurrencyUrl = GetConfigurationValue<string>('currency.asset.icon.url', '');
return configuredCurrencyUrl.replace('%type%', type.toString());
};
const onMouseEvent = (event: MouseEvent) =>
{
switch(event.type)
@@ -74,9 +115,30 @@ export const CatalogGridOfferView: FC<CatalogGridOfferViewProps> = props =>
{ ...rest }
>
{ iconUrl && !(offer.product.productType === ProductTypeEnum.ROBOT) &&
<div className="nitro-catalog-classic-grid-offer-icon" style={ { backgroundImage: `url(${ iconUrl })` } } /> }
<img
className="nitro-catalog-classic-grid-offer-icon"
src={ iconUrl }
draggable={ false }
onError={ event =>
{
const fallbackIconUrl = product.getIconUrl(offer);
if(fallbackIconUrl && (event.currentTarget.src !== fallbackIconUrl)) event.currentTarget.src = fallbackIconUrl;
} } /> }
{ (offer.product.productType === ProductTypeEnum.ROBOT) &&
<LayoutAvatarImageView direction={ 2 } figure={ offer.product.extraParam } fit /> }
{ (prices.length > 0) &&
<span className={ `nitro-catalog-classic-grid-price ${ prices.length > 1 ? 'is-multi-price' : 'is-single-price' }` }>
{ prices.map((price, index) =>
<span key={ `${ price.type }-${ index }` } className="nitro-catalog-classic-grid-price-entry">
{ index > 0 && <span className="nitro-catalog-classic-grid-price-plus">+</span> }
<span className="nitro-catalog-classic-grid-price-amount">{ price.amount }</span>
<img
className="nitro-catalog-classic-grid-price-currency"
src={ getCurrencyIconUrl(price.type) }
draggable={ false } />
</span>) }
</span> }
<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 =>
@@ -1,5 +1,5 @@
import { FC } from 'react';
import { FaEdit, FaPlus, FaPowerOff, FaSyncAlt } from 'react-icons/fa';
import { FaEdit, FaExchangeAlt, FaPlus, FaSyncAlt } from 'react-icons/fa';
import { GetConfigurationValue, LocalizeText, ProductTypeEnum, SanitizeHtml } from '../../../../../api';
import { Text } from '../../../../../common';
import { useCatalogData } from '../../../../../hooks';
@@ -20,6 +20,7 @@ export const CatalogLayoutDefaultView: FC<CatalogLayoutProps> = props =>
const { currentOffer = null, currentPage = null, roomPreviewer = null } = useCatalogData();
const catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
const offerName = currentOffer?.localizationName?.replace(/\s*\([^)]*\)\s*$/g, '');
return (
<div className="nitro-catalog-classic-default-layout flex flex-col h-full gap-2">
@@ -40,63 +41,87 @@ export const CatalogLayoutDefaultView: FC<CatalogLayoutProps> = props =>
>
<FaPlus className="text-[10px]" /> { LocalizeText('catalog.admin.offer.new') }
</button>
{ currentOffer &&
<button
className="flex items-center gap-1 text-[10px] text-primary hover:text-dark transition-colors cursor-pointer"
title={ `${ LocalizeText('catalog.admin.offer.edit') } - Class ${ currentOffer.product.productClassId } / Offer ${ currentOffer.offerId }` }
onClick={ () => catalogAdmin.setEditingOffer(currentOffer) }
>
<FaEdit className="text-[10px]" /> { LocalizeText('catalog.admin.offer.edit') }
<span className="font-mono text-[9px] text-dark font-semibold">#{ currentOffer.product.productClassId }/{ currentOffer.offerId }</span>
</button> }
</div> }
{ currentOffer &&
<div className="nitro-catalog-classic-offer-panel flex gap-0 shrink-0">
<div className="nitro-catalog-classic-offer-preview relative flex items-center justify-center">
{ (currentOffer.product.productType !== ProductTypeEnum.BADGE) &&
<>
<button className="nitro-catalog-classic-preview-btn nitro-catalog-classic-preview-rotate" onClick={ () => roomPreviewer?.changeRoomObjectDirection() }>
<FaSyncAlt /> Rotate
</button>
<button className="nitro-catalog-classic-preview-btn nitro-catalog-classic-preview-state" onClick={ () => roomPreviewer?.changeRoomObjectState() }>
<FaPowerOff /> Toggle State
</button>
<CatalogViewProductWidgetView />
<CatalogAddOnBadgeWidgetView className="bg-muted rounded bottom-1 right-1 absolute" />
</> }
{ (currentOffer.product.productType === ProductTypeEnum.BADGE) &&
<CatalogAddOnBadgeWidgetView className="scale-2" /> }
</div>
<div className="nitro-catalog-classic-offer-info flex flex-col flex-1 min-w-0 gap-2">
<div>
<div className="flex items-start justify-between gap-2">
<Text className="text-[13px]! font-bold text-dark leading-tight">{ currentOffer.localizationName }</Text>
<div className="nitro-catalog-classic-product-view">
{ currentOffer &&
<div className="nitro-catalog-classic-offer-panel flex gap-0">
<div className="nitro-catalog-classic-offer-preview relative flex items-center justify-center">
<Text className="nitro-catalog-classic-preview-title">{ offerName }</Text>
{ (currentOffer.product.productType !== ProductTypeEnum.BADGE) &&
<>
<button className="nitro-catalog-classic-preview-btn nitro-catalog-classic-preview-rotate" onClick={ () => roomPreviewer?.changeRoomObjectDirection() }>
<FaSyncAlt />
</button>
<button className="nitro-catalog-classic-preview-btn nitro-catalog-classic-preview-state" onClick={ () => roomPreviewer?.changeRoomObjectState() }>
<FaExchangeAlt />
</button>
<CatalogViewProductWidgetView />
<CatalogAddOnBadgeWidgetView className="bg-muted rounded bottom-1 right-1 absolute" />
</> }
{ (currentOffer.product.productType === ProductTypeEnum.BADGE) &&
<CatalogAddOnBadgeWidgetView className="scale-2" /> }
</div>
<div className="nitro-catalog-classic-offer-info flex flex-col flex-1 min-w-0 gap-2">
<div>
<div className="flex items-start justify-between gap-2">
<Text className="text-[13px]! font-bold text-dark leading-tight">{ offerName }</Text>
{ adminMode &&
<FaEdit
className="text-primary text-[11px] cursor-pointer hover:text-dark transition-colors shrink-0 mt-0.5"
title={ LocalizeText('catalog.admin.offer.edit') }
onClick={ () => catalogAdmin.setEditingOffer(currentOffer) }
/> }
</div>
{ adminMode &&
<FaEdit
className="text-primary text-[11px] cursor-pointer hover:text-dark transition-colors shrink-0 mt-0.5"
title={ LocalizeText('catalog.admin.offer.edit') }
onClick={ () => catalogAdmin.setEditingOffer(currentOffer) }
/> }
<div className="flex items-center gap-1 mt-1 flex-wrap">
<span className="text-[8px] font-mono text-white bg-gray-600 px-1 py-px rounded">ID: { currentOffer.product.productClassId }</span>
<span className="text-[8px] font-mono text-white bg-primary px-1 py-px rounded">Offer: { currentOffer.offerId }</span>
<span className="text-[8px] font-mono text-white bg-secondary px-1 py-px rounded">{ currentOffer.product.productType.toUpperCase() }</span>
</div> }
<CatalogLimitedItemWidgetView />
</div>
{ adminMode &&
<div className="flex items-center gap-1 mt-1 flex-wrap">
<span className="text-[8px] font-mono text-white bg-gray-600 px-1 py-px rounded">ID: { currentOffer.product.productClassId }</span>
<span className="text-[8px] font-mono text-white bg-primary px-1 py-px rounded">Offer: { currentOffer.offerId }</span>
<span className="text-[8px] font-mono text-white bg-secondary px-1 py-px rounded">{ currentOffer.product.productType.toUpperCase() }</span>
</div> }
<CatalogLimitedItemWidgetView />
</div>
<CatalogTotalPriceWidget />
<CatalogSpinnerWidgetView />
<div className="nitro-catalog-classic-offer-actions flex gap-1.5">
<CatalogPurchaseWidgetView />
</div>
</div>
</div> }
</div> }
{ !currentOffer &&
<div className="nitro-catalog-classic-welcome flex items-center gap-3 shrink-0">
{ !!page.localization.getImage(1) &&
<img className="w-[70px] h-[70px] object-contain rounded shrink-0" src={ page.localization.getImage(1) } /> }
<Text className="text-[11px]! text-muted" dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } />
</div> }
{ !currentOffer &&
<div className="nitro-catalog-classic-welcome flex items-center gap-3">
{ !!page.localization.getImage(1) &&
<img className="w-[70px] h-[70px] object-contain rounded shrink-0" src={ page.localization.getImage(1) } /> }
<Text className="text-[11px]! text-muted" dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } />
</div> }
</div>
<div className="nitro-catalog-classic-grid-shell flex-1 overflow-auto min-h-0">
{ GetConfigurationValue('catalog.headers') &&
<CatalogHeaderView imageUrl={ currentPage.localization.getImage(0) } /> }
<CatalogItemGridWidgetView className="nitro-catalog-classic-grid" columnCount={ 7 } columnMinHeight={ currentPage.layoutCode === 'bots' ? 65 : 50 } columnMinWidth={ currentPage.layoutCode === 'bots' ? 65 : 50 } />
<CatalogItemGridWidgetView className="nitro-catalog-classic-grid" columnCount={ 7 } columnMinHeight={ 70 } columnMinWidth={ 45 } />
</div>
{ currentOffer &&
<div className="nitro-catalog-classic-price-row flex items-center justify-between gap-2">
<div className="nitro-catalog-classic-spinner-slot">
<CatalogSpinnerWidgetView />
</div>
<div className="nitro-catalog-classic-total-price-slot">
<CatalogTotalPriceWidget />
</div>
</div> }
{ currentOffer &&
<div className="nitro-catalog-classic-purchase-row flex items-start justify-end">
<div className="nitro-catalog-classic-offer-actions flex gap-1.5">
<CatalogPurchaseWidgetView />
</div>
</div> }
</div>
);
};
@@ -17,14 +17,14 @@ export const CatalogLayouGuildCustomFurniView: FC<CatalogLayoutProps> = props =>
return (
<Grid>
<Column overflow="hidden" size={ 7 }>
<CatalogItemGridWidgetView />
<Column overflow="hidden" size={ 8 }>
<CatalogItemGridWidgetView columnMinWidth={ 36 } />
</Column>
<Column center={ !currentOffer } overflow="hidden" size={ 5 }>
<Column center={ !currentOffer } overflow="hidden" size={ 4 }>
{ !currentOffer &&
<>
{ !!page.localization.getImage(1) && <img alt="" src={ page.localization.getImage(1) } /> }
<Text center dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } />
{ !!page.localization.getImage(1) && <img alt="" className="max-w-full object-contain" src={ page.localization.getImage(1) } /> }
<Text center bold dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } />
</> }
{ currentOffer &&
<>
@@ -33,7 +33,7 @@ export const CatalogLayouGuildCustomFurniView: FC<CatalogLayoutProps> = props =>
<CatalogGuildBadgeWidgetView className="bottom-1 inset-e-1" position="absolute" />
</div>
<Column grow gap={ 1 }>
<Text truncate>{ currentOffer.localizationName }</Text>
<Text bold className="leading-tight">{ currentOffer.localizationName }</Text>
<div className="grow!">
<CatalogGuildSelectorWidgetView />
</div>
@@ -20,6 +20,7 @@ export const CatalogLayoutRoomAdsView: FC<CatalogLayoutProps> = props =>
const { categories } = useNavigatorData();
const { setIsVisible = null } = useCatalogUiState();
const { promoteInformation, isExtended, setIsExtended } = useRoomPromote();
const promoteData = promoteInformation?.data ?? null;
const { data: availableRooms = [] } = useNitroQuery<RoomAdPurchaseInfoEvent, RoomEntryData[]>({
key: [ 'nitro', 'catalog', 'room-ad-purchase-info' ],
@@ -31,17 +32,17 @@ export const CatalogLayoutRoomAdsView: FC<CatalogLayoutProps> = props =>
useEffect(() =>
{
if(isExtended)
if(isExtended && promoteData)
{
setRoomId(promoteInformation.data.flatId);
setEventName(promoteInformation.data.eventName);
setEventDesc(promoteInformation.data.eventDescription);
setCategoryId(promoteInformation.data.categoryId);
setRoomId(promoteData.flatId);
setEventName(promoteData.eventName);
setEventDesc(promoteData.eventDescription);
setCategoryId(promoteData.categoryId);
setExtended(isExtended); // This is for sending to packet
setIsExtended(false); // This is from hook useRoomPromotte
}
}, [ isExtended, eventName, eventDesc, categoryId, promoteInformation.data, setIsExtended ]);
}, [ isExtended, promoteData, setIsExtended ]);
const resetData = () =>
{
@@ -28,7 +28,7 @@ export const CatalogLayoutSingleBundleView: FC<CatalogLayoutProps> = props =>
<Text center small overflow="auto">{ page.localization.getText(1) }</Text> }
<Column grow gap={ 0 } overflow="hidden" position="relative">
{ !!page.localization.getImage(1) &&
<img alt="" className="grow!" src={ page.localization.getImage(1) } /> }
<img alt="" className="grow! min-h-0 w-full h-full object-contain object-center" src={ page.localization.getImage(1) } /> }
<CatalogAddOnBadgeWidgetView className="bg-muted rounded bottom-0 inset-s-0" position="absolute" />
<CatalogSimplePriceWidgetView />
</Column>
@@ -1,7 +1,6 @@
import { ICatalogPage } from '../../../../../api';
import { CatalogLayoutProps } from './CatalogLayout.types';
import { CatalogLayoutBadgeDisplayView } from './CatalogLayoutBadgeDisplayView';
import { CatalogLayoutBcInfoView } from './CatalogLayoutBcInfoView';
import { CatalogLayoutBuildersClubBuyView } from './CatalogLayoutBuildersClubBuyView';
import { CatalogLayoutColorGroupingView } from './CatalogLayoutColorGroupingView';
import { CatalogLayoutCustomPrefixView } from './CatalogLayoutCustomPrefixView';
@@ -35,8 +34,6 @@ export const GetCatalogLayout = (page: ICatalogPage, hideNavigation: () => void)
{
case 'frontpage_featured':
return null;
case 'info_duckets':
return <CatalogLayoutBcInfoView { ...layoutProps } />;
case 'frontpage4':
return <CatalogLayoutFrontpage4View { ...layoutProps } />;
case 'pets':
@@ -206,7 +206,7 @@ export const CatalogLayoutPetView: FC<CatalogLayoutProps> = props =>
</div> }
{ /* Top card: preview + name + purchase */ }
<div className="flex gap-3 p-2.5 bg-white rounded border-2 border-card-grid-item-border">
<div className="nitro-catalog-classic-pet-card flex gap-3 p-2.5 bg-white rounded border-2 border-card-grid-item-border">
{ /* Pet preview */ }
<div className="w-[160px] min-w-[160px] h-[140px] rounded overflow-hidden bg-card-grid-item relative flex items-center justify-center border border-card-grid-item-border">
<CatalogViewProductWidgetView />
@@ -240,12 +240,12 @@ export const CatalogLayoutPetView: FC<CatalogLayoutProps> = props =>
<span className="text-[8px] font-mono text-white bg-primary px-1 py-px rounded">Offer: { currentOffer.offerId }</span>
</div> }
{ !!page.localization.getText(0) &&
<p className="text-[10px] text-muted mt-0.5" dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } /> }
<p className="text-[10px] text-dark mt-0.5" dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } /> }
</div>
{ /* Name input */ }
<div className="flex flex-col gap-1 mt-2">
<label className="text-[9px] text-muted uppercase font-bold">{ LocalizeText('widgets.petpackage.name.title') }</label>
<label className="text-[9px] text-dark uppercase font-bold">{ LocalizeText('widgets.petpackage.name.title') }</label>
<div className="relative">
<input
className={ `w-full text-[11px] border-2 rounded px-2 py-1.5 focus:outline-none transition-colors ${ approvalResult > 0 ? 'border-danger bg-danger/5' : approvalResult === 0 ? 'border-success bg-success/5' : 'border-card-grid-item-border focus:border-primary bg-white' }` }
@@ -267,7 +267,7 @@ export const CatalogLayoutPetView: FC<CatalogLayoutProps> = props =>
<div className="flex items-center justify-between mt-2">
<CatalogTotalPriceWidget />
<button
className="px-3 py-1 rounded text-[11px] font-bold bg-primary text-white hover:bg-secondary transition-colors cursor-pointer disabled:opacity-50"
className="nitro-catalog-swf-button nitro-catalog-swf-buy-button"
disabled={ !petName.length || (approvalResult > 0) }
onClick={ purchasePet }
>
@@ -280,7 +280,7 @@ export const CatalogLayoutPetView: FC<CatalogLayoutProps> = props =>
{ /* Breed/Color grid */ }
<div className="flex-1 overflow-auto min-h-0">
<div className="flex items-center gap-1.5 mb-1.5">
<span className="text-[10px] font-bold text-muted uppercase tracking-wide">
<span className="text-[10px] font-bold text-dark uppercase tracking-wide">
{ colorsShowing ? LocalizeText('catalog.pets.choose.color') : LocalizeText('catalog.pets.choose.breed') }
</span>
{ colorsShowing &&
@@ -47,7 +47,7 @@ export const CatalogGuildSelectorWidgetView: FC<{}> = props =>
return (
<div className="bg-muted rounded p-1 text-black text-center">
{ LocalizeText('catalog.guild_selector.members_only') }
<Button className="mt-1">
<Button fullWidth classNames={ [ 'mt-1', 'nitro-catalog-swf-button', 'nitro-catalog-swf-buy-button', 'whitespace-normal!', 'text-[10px]!', 'leading-tight!', 'py-1!' ] }>
{ LocalizeText('catalog.guild_selector.find_groups') }
</Button>
</div>
@@ -19,17 +19,17 @@ export const CatalogPriceDisplayWidgetView: FC<CatalogPriceDisplayWidgetViewProp
if(!offer) return null;
return (
<div className="flex items-center gap-1.5">
<div className="nitro-catalog-swf-price-display">
{ (offer.priceInCredits > 0) &&
<div className="flex items-center gap-1 bg-warning/15 border border-warning/40 rounded-full px-2 py-0.5">
<Text className="text-[11px]! font-bold text-dark">{ (offer.priceInCredits * quantity) }</Text>
<div className="nitro-catalog-swf-price-pill">
<Text className="nitro-catalog-swf-price-text">{ (offer.priceInCredits * quantity) }</Text>
<LayoutCurrencyIcon type={ -1 } />
</div> }
{ separator && (offer.priceInCredits > 0) && (offer.priceInActivityPoints > 0) &&
<FaPlus className="text-[7px] text-muted" /> }
<FaPlus className="nitro-catalog-swf-price-plus" /> }
{ (offer.priceInActivityPoints > 0) &&
<div className="flex items-center gap-1 bg-purple/15 border border-purple/40 rounded-full px-2 py-0.5">
<Text className="text-[11px]! font-bold text-dark">{ (offer.priceInActivityPoints * quantity) }</Text>
<div className="nitro-catalog-swf-price-pill">
<Text className="nitro-catalog-swf-price-text">{ (offer.priceInActivityPoints * quantity) }</Text>
<LayoutCurrencyIcon type={ offer.activityPointType } />
</div> }
</div>
@@ -171,6 +171,8 @@ export const CatalogPurchaseWidgetView: FC<CatalogPurchaseWidgetViewProps> = pro
const PurchaseButton = () =>
{
const swfButtonClassNames = [ 'nitro-catalog-swf-button' ];
if(isBuildersClubPlaceable)
{
const hasMissingExtraParam = (purchaseOptions.extraParamRequired && (!purchaseOptions.extraData || !purchaseOptions.extraData.length));
@@ -198,10 +200,10 @@ export const CatalogPurchaseWidgetView: FC<CatalogPurchaseWidgetViewProps> = pro
return (
<div className="flex flex-col gap-1.5 items-start">
<div className="flex gap-1.5 flex-wrap">
<Button disabled={ isDisabled } onClick={ () => startBuilderPlacement(true) }>
<Button classNames={ swfButtonClassNames } disabled={ isDisabled } onClick={ () => startBuilderPlacement(true) }>
{ LocalizeText('builder.placement_widget.place_many') }
</Button>
<Button disabled={ isDisabled } onClick={ () => startBuilderPlacement(false) } style={ buildersClubPlaceOneButtonStyle }>
<Button classNames={ swfButtonClassNames } disabled={ isDisabled } onClick={ () => startBuilderPlacement(false) } style={ buildersClubPlaceOneButtonStyle }>
{ LocalizeText('builder.placement_widget.place_one') }
</Button>
</div>
@@ -220,37 +222,37 @@ export const CatalogPurchaseWidgetView: FC<CatalogPurchaseWidgetViewProps> = pro
const priceCredits = (currentOffer.priceInCredits * purchaseOptions.quantity);
const pricePoints = (currentOffer.priceInActivityPoints * purchaseOptions.quantity);
if(GetClubMemberLevel() < currentOffer.clubLevel) return <Button disabled variant="danger">{ LocalizeText('catalog.alert.hc.required') }</Button>;
if(GetClubMemberLevel() < currentOffer.clubLevel) return <Button classNames={ swfButtonClassNames } disabled variant="danger">{ LocalizeText('catalog.alert.hc.required') }</Button>;
if(isLimitedSoldOut) return <Button disabled variant="danger">{ LocalizeText('catalog.alert.limited_edition_sold_out.title') }</Button>;
if(isLimitedSoldOut) return <Button classNames={ swfButtonClassNames } disabled variant="danger">{ LocalizeText('catalog.alert.limited_edition_sold_out.title') }</Button>;
if(priceCredits > getCurrencyAmount(-1)) return <Button disabled variant="danger">{ LocalizeText('catalog.alert.notenough.title') }</Button>;
if(priceCredits > getCurrencyAmount(-1)) return <Button classNames={ swfButtonClassNames } disabled variant="danger">{ LocalizeText('catalog.alert.notenough.title') }</Button>;
if(pricePoints > getCurrencyAmount(currentOffer.activityPointType)) return <Button disabled variant="danger">{ LocalizeText('catalog.alert.notenough.activitypoints.title.' + currentOffer.activityPointType) }</Button>;
if(pricePoints > getCurrencyAmount(currentOffer.activityPointType)) return <Button classNames={ swfButtonClassNames } disabled variant="danger">{ LocalizeText('catalog.alert.notenough.activitypoints.title.' + currentOffer.activityPointType) }</Button>;
switch(purchaseState)
{
case CatalogPurchaseState.CONFIRM:
return <Button variant="warning" onClick={ event => purchase() }>{ LocalizeText('catalog.marketplace.confirm_title') }</Button>;
return <Button classNames={ swfButtonClassNames } variant="warning" onClick={ event => purchase() }>{ LocalizeText('catalog.marketplace.confirm_title') }</Button>;
case CatalogPurchaseState.PURCHASE:
return <Button disabled><LayoutLoadingSpinnerView /></Button>;
return <Button classNames={ swfButtonClassNames } disabled><LayoutLoadingSpinnerView /></Button>;
case CatalogPurchaseState.FAILED:
return <Button variant="danger">{ LocalizeText('generic.failed') }</Button>;
return <Button classNames={ swfButtonClassNames } variant="danger">{ LocalizeText('generic.failed') }</Button>;
case CatalogPurchaseState.SOLD_OUT:
return <Button variant="danger">{ LocalizeText('generic.failed') + ' - ' + LocalizeText('catalog.alert.limited_edition_sold_out.title') }</Button>;
return <Button classNames={ swfButtonClassNames } variant="danger">{ LocalizeText('generic.failed') + ' - ' + LocalizeText('catalog.alert.limited_edition_sold_out.title') }</Button>;
case CatalogPurchaseState.NONE:
default:
return <Button variant="success" disabled={ (purchaseOptions.extraParamRequired && (!purchaseOptions.extraData || !purchaseOptions.extraData.length)) } onClick={ event => setPurchaseState(CatalogPurchaseState.CONFIRM) }>{ LocalizeText('catalog.purchase_confirmation.' + (currentOffer.isRentOffer ? 'rent' : 'buy')) }</Button>;
return <Button classNames={ [ ...swfButtonClassNames, 'nitro-catalog-swf-buy-button' ] } variant="success" disabled={ (purchaseOptions.extraParamRequired && (!purchaseOptions.extraData || !purchaseOptions.extraData.length)) } onClick={ event => setPurchaseState(CatalogPurchaseState.CONFIRM) }>{ LocalizeText('catalog.purchase_confirmation.' + (currentOffer.isRentOffer ? 'rent' : 'buy')) }</Button>;
}
};
return (
<>
<PurchaseButton />
{ (!isBuildersClubOffer && !noGiftOption && !currentOffer.isRentOffer) &&
<Button disabled={ ((purchaseOptions.quantity > 1) || !currentOffer.giftable || isLimitedSoldOut || (purchaseOptions.extraParamRequired && (!purchaseOptions.extraData || !purchaseOptions.extraData.length))) } onClick={ event => purchase(true) }>
<Button classNames={ [ 'nitro-catalog-swf-button', 'nitro-catalog-swf-gift-button' ] } disabled={ ((purchaseOptions.quantity > 1) || !currentOffer.giftable || isLimitedSoldOut || (purchaseOptions.extraParamRequired && (!purchaseOptions.extraData || !purchaseOptions.extraData.length))) } onClick={ event => purchase(true) }>
{ LocalizeText('catalog.purchase_confirmation.gift') }
</Button> }
<PurchaseButton />
</>
);
};
@@ -34,26 +34,26 @@ export const CatalogSpinnerWidgetView: FC<{}> = props =>
if(!currentOffer || !currentOffer.bundlePurchaseAllowed) return null;
return (
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-muted whitespace-nowrap">{ LocalizeText('catalog.bundlewidget.spinner.select.amount') }</span>
<div className="flex items-center rounded overflow-hidden border-2 border-card-grid-item-border">
<div className="nitro-catalog-swf-spinner">
<span className="nitro-catalog-swf-spinner-label">{ LocalizeText('catalog.bundlewidget.quantity') }</span>
<div className="nitro-catalog-swf-spinner-control">
<button
className="w-[24px] h-[24px] flex items-center justify-center bg-card-grid-item hover:bg-card-grid-item-active transition-colors cursor-pointer border-r border-card-grid-item-border"
className="nitro-catalog-swf-spinner-button nitro-catalog-swf-spinner-button-less"
onClick={ event => updateQuantity(quantity - 1) }
>
<FaMinus className="text-[7px] text-dark" />
<FaMinus />
</button>
<input
className="w-[40px] h-[24px] text-center text-[11px] font-bold bg-white border-x border-card-grid-item-border [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none focus:outline-none"
className="nitro-catalog-swf-spinner-input"
type="number"
value={ quantity }
onChange={ event => updateQuantity(event.target.valueAsNumber) }
/>
<button
className="w-[24px] h-[24px] flex items-center justify-center bg-card-grid-item hover:bg-card-grid-item-active transition-colors cursor-pointer border-l border-card-grid-item-border"
className="nitro-catalog-swf-spinner-button nitro-catalog-swf-spinner-button-more"
onClick={ event => updateQuantity(quantity + 1) }
>
<FaPlus className="text-[7px] text-dark" />
<FaPlus />
</button>
</div>
</div>
@@ -119,5 +119,5 @@ export const CatalogViewProductWidgetView: FC<{}> = props =>
);
}
return <LayoutRoomPreviewerView height={ 140 } roomPreviewer={ roomPreviewer } />;
return <LayoutRoomPreviewerView height={ 240 } roomPreviewer={ roomPreviewer } />;
};
@@ -7,11 +7,25 @@ interface ChatInputCommandSelectorViewProps
selectedIndex: number;
onSelect: (command: CommandDefinition) => void;
onHover: (index: number) => void;
/**
* When true, render the flat minimalist look (gray list, dark-blue
* selection). When false / undefined (default) the picker wears the
* Habbo NitroCard chrome with the green :command header strip.
*/
newStyle?: boolean;
}
/**
* :command autocomplete popover. Two visual modes, both driven by the
* "New style" toggle in user settings (memenu.settings.other.catalog.classic.style):
*
* - newStyle = false (default): cream cardstock, habbo-green header,
* UbuntuCondensed names, green ":" tile, custom Habbo scrollbar.
* - newStyle = true: flat gray list, dark-blue selection, plain text rows.
*/
export const ChatInputCommandSelectorView: FC<ChatInputCommandSelectorViewProps> = props =>
{
const { commands = [], selectedIndex = 0, onSelect = null, onHover = null } = props;
const { commands = [], selectedIndex = 0, onSelect = null, onHover = null, newStyle = false } = props;
const listRef = useRef<HTMLDivElement>(null);
useEffect(() =>
@@ -23,19 +37,57 @@ export const ChatInputCommandSelectorView: FC<ChatInputCommandSelectorViewProps>
if(selected) selected.scrollIntoView({ block: 'nearest' });
}, [ selectedIndex ]);
if(newStyle)
{
return (
<div ref={ listRef } className="absolute bottom-full left-0 w-full bg-[#e8e8e8] border-2 border-black border-b-0 rounded-t-lg max-h-[240px] overflow-y-auto z-[1070]">
{ commands.map((cmd, index) => (
<div
key={ cmd.key }
className={ `px-3 py-1.5 cursor-pointer text-sm flex items-center gap-2 ${ index === selectedIndex ? 'bg-[#283F5D] text-white' : 'hover:bg-gray-300' }` }
onClick={ () => onSelect(cmd) }
onMouseEnter={ () => onHover(index) }
>
<span className="font-bold">:{ cmd.key }</span>
<span className={ `text-xs ${ index === selectedIndex ? 'text-gray-300' : 'text-gray-500' }` }>{ cmd.description }</span>
</div>
)) }
</div>
);
}
return (
<div ref={ listRef } className="absolute bottom-full left-0 w-full bg-[#e8e8e8] border-2 border-black border-b-0 rounded-t-lg max-h-[240px] overflow-y-auto z-[1070]">
{ commands.map((cmd, index) => (
<div
key={ cmd.key }
className={ `px-3 py-1.5 cursor-pointer text-sm flex items-center gap-2 ${ index === selectedIndex ? 'bg-[#283F5D] text-white' : 'hover:bg-gray-300' }` }
onClick={ () => onSelect(cmd) }
onMouseEnter={ () => onHover(index) }
>
<span className="font-bold">:{ cmd.key }</span>
<span className={ `text-xs ${ index === selectedIndex ? 'text-gray-300' : 'text-gray-500' }` }>{ cmd.description }</span>
</div>
)) }
<div className="chat-input-command-popover">
<div className="chat-input-command-popover-header">
<span className="chat-input-command-popover-header-dot" aria-hidden />
<span>: Command</span>
</div>
<div ref={ listRef } className="chat-input-command-popover-list">
{ commands.map((cmd, index) =>
{
const isSelected = (index === selectedIndex);
const rowClass = [
'chat-input-command-row',
isSelected ? 'is-selected' : ''
].filter(Boolean).join(' ');
return (
<div
key={ cmd.key }
className={ rowClass }
onClick={ () => onSelect(cmd) }
onMouseEnter={ () => onHover(index) }
>
<div className="chat-input-command-row-tile">:</div>
<div className="chat-input-command-row-body">
<span className="chat-input-command-row-name">:{ cmd.key }</span>
{ cmd.description &&
<span className="chat-input-command-row-desc">{ cmd.description }</span> }
</div>
</div>
);
}) }
</div>
</div>
);
};
@@ -19,11 +19,28 @@ interface ChatInputMentionSelectorViewProps
selectedIndex: number;
onSelect: (suggestion: MentionSuggestion) => void;
onHover: (index: number) => void;
/**
* When true, render the flat minimalist look (gray list, dark-blue
* selection, no header / no kind chip). When false / undefined (default)
* the picker wears the Habbo NitroCard chrome.
*/
newStyle?: boolean;
}
/**
* @-mention autocomplete popover. Two visual modes, both driven by the
* "New style" toggle in user settings (memenu.settings.other.catalog.classic.style):
*
* - newStyle = false (default): cream cardstock, habbo-blue header,
* UbuntuCondensed names, kind chips, custom Habbo scrollbar.
* - newStyle = true: flat gray list, dark-blue selection, plain text rows.
*
* Both modes share the same suggestion structure and keyboard contract -
* the difference is purely cosmetic.
*/
export const ChatInputMentionSelectorView: FC<ChatInputMentionSelectorViewProps> = props =>
{
const { suggestions = [], selectedIndex = 0, onSelect = null, onHover = null } = props;
const { suggestions = [], selectedIndex = 0, onSelect = null, onHover = null, newStyle = false } = props;
const listRef = useRef<HTMLDivElement>(null);
useEffect(() =>
@@ -37,39 +54,92 @@ export const ChatInputMentionSelectorView: FC<ChatInputMentionSelectorViewProps>
if(suggestions.length === 0) return null;
return (
<div ref={ listRef } className="absolute bottom-full left-0 w-full bg-[#e8e8e8] border-2 border-black border-b-0 rounded-t-lg max-h-[240px] overflow-y-auto z-[1070]">
{ suggestions.map((suggestion, index) =>
{
const isSelected = (index === selectedIndex);
const rowClass = `px-3 py-1.5 cursor-pointer text-sm flex items-center gap-2 ${ isSelected ? 'bg-[#283F5D] text-white' : 'hover:bg-gray-300' }`;
if(newStyle)
{
return (
<div ref={ listRef } className="absolute bottom-full left-0 w-full bg-[#e8e8e8] border-2 border-black border-b-0 rounded-t-lg max-h-[240px] overflow-y-auto z-[1070]">
{ suggestions.map((suggestion, index) =>
{
const isSelected = (index === selectedIndex);
const rowClass = `px-3 py-1.5 cursor-pointer text-sm flex items-center gap-2 ${ isSelected ? 'bg-[#283F5D] text-white' : 'hover:bg-gray-300' }`;
return (
<div
key={ suggestion.key }
className={ rowClass }
onClick={ () => onSelect(suggestion) }
onMouseEnter={ () => onHover(index) }
>
{ suggestion.kind === 'user' && suggestion.figure
? (
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-full bg-black/10">
<LayoutAvatarImageView
figure={ suggestion.figure }
direction={ 2 }
headOnly
style={ { backgroundSize: 'auto', backgroundPosition: '-22px -32px' } }
/>
</div>
)
: (
<div className="flex items-center justify-center h-11 w-11 rounded-full bg-black/20 text-white text-[14px] font-bold shrink-0">@</div>
) }
<span className="font-bold">@{ suggestion.name }</span>
{ suggestion.description && <span className={ `text-xs ${ isSelected ? 'text-gray-300' : 'text-gray-500' }` }>{ suggestion.description }</span> }
</div>
);
}) }
return (
<div
key={ suggestion.key }
className={ rowClass }
onClick={ () => onSelect(suggestion) }
onMouseEnter={ () => onHover(index) }
>
{ suggestion.kind === 'user' && suggestion.figure
? (
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-full bg-black/10">
<LayoutAvatarImageView
figure={ suggestion.figure }
direction={ 2 }
headOnly
style={ { backgroundSize: 'auto', backgroundPosition: '-22px -32px' } }
/>
</div>
)
: (
<div className="flex items-center justify-center h-11 w-11 rounded-full bg-black/20 text-white text-[14px] font-bold shrink-0">@</div>
) }
<span className="font-bold">@{ suggestion.name }</span>
{ suggestion.description && <span className={ `text-xs ${ isSelected ? 'text-gray-300' : 'text-gray-500' }` }>{ suggestion.description }</span> }
</div>
);
}) }
</div>
);
}
return (
<div className="chat-input-mention-popover">
<div className="chat-input-mention-popover-header">
<span className="chat-input-mention-popover-header-dot" aria-hidden />
<span>@ Mention</span>
</div>
<div ref={ listRef } className="chat-input-mention-popover-list">
{ suggestions.map((suggestion, index) =>
{
const isSelected = (index === selectedIndex);
const rowClass = [
'chat-input-mention-row',
isSelected ? 'is-selected' : ''
].filter(Boolean).join(' ');
return (
<div
key={ suggestion.key }
className={ rowClass }
onClick={ () => onSelect(suggestion) }
onMouseEnter={ () => onHover(index) }
>
{ suggestion.kind === 'user' && suggestion.figure
? (
<div className="chat-input-mention-row-tile">
<LayoutAvatarImageView
figure={ suggestion.figure }
direction={ 2 }
headOnly
/>
</div>
)
: (
<div className="chat-input-mention-row-tile is-alias">@</div>
) }
<div className="chat-input-mention-row-body">
<span className="chat-input-mention-row-name">@{ suggestion.name }</span>
{ suggestion.description &&
<span className="chat-input-mention-row-desc">{ suggestion.description }</span> }
</div>
<span className={ `chat-input-mention-row-kind ${ suggestion.kind === 'alias' ? 'is-alias' : '' }` }>
{ suggestion.kind === 'alias' ? 'Broadcast' : 'User' }
</span>
</div>
);
}) }
</div>
</div>
);
};
@@ -3,7 +3,7 @@ import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ChatMessageTypeEnum, GetClubMemberLevel, GetConfigurationValue, LocalizeText, RoomWidgetUpdateChatInputContentEvent } from '../../../../api';
import { Text } from '../../../../common';
import { useChatCommandSelector, useChatInputWidget, useRoom, useSessionInfo, useUiEvent } from '../../../../hooks';
import { useCatalogClassicStyle, useChatCommandSelector, useChatInputWidget, useRoom, useSessionInfo, useUiEvent } from '../../../../hooks';
import { useRoomUserListSnapshot } from '../../../../hooks/session/useSessionSnapshots';
import { ChatInputCommandSelectorView } from './ChatInputCommandSelectorView';
import { ChatInputEmojiSelectorView } from './ChatInputEmojiSelectorView';
@@ -58,6 +58,11 @@ export const ChatInputView: FC<{}> = props =>
const roomUserList = useRoomUserListSnapshot();
const [ mentionSelectedIndex, setMentionSelectedIndex ] = useState<number>(0);
// The "New style" user-setting (memenu.settings.other.catalog.classic.style)
// drives BOTH the catalog layout and the mention-picker chrome:
// false (default) = Habbo old-school NitroCard cardstock look
// true = flat minimalist gray look
const [ newStyle ] = useCatalogClassicStyle();
const mentionContext = useMemo(() =>
{
@@ -485,6 +490,7 @@ export const ChatInputView: FC<{}> = props =>
setChatValue(':' + cmd.key + ' '); inputRef.current?.focus();
} }
onHover={ setSelectedIndex }
newStyle={ newStyle }
/> }
{ mentionSelectorVisible && !commandSelectorVisible &&
<ChatInputMentionSelectorView
@@ -492,6 +498,7 @@ export const ChatInputView: FC<{}> = props =>
selectedIndex={ mentionSelectedIndex }
onSelect={ applyMentionSuggestion }
onHover={ setMentionSelectedIndex }
newStyle={ newStyle }
/> }
<div className="flex-1 items-center input-sizer">
{ !floodBlocked &&