feat: add builders club catalog ui flow

This commit is contained in:
Lorenzune
2026-04-07 14:40:51 +02:00
parent d271264b87
commit 954e477e47
26 changed files with 840 additions and 132 deletions
@@ -7,4 +7,5 @@ export class BuilderFurniPlaceableStatus
public static NOT_ROOM_OWNER: number = 4;
public static GUILD_ROOM: number = 5;
public static VISITORS_IN_ROOM: number = 6;
public static NOT_GROUP_ADMIN: number = 7;
}
+1
View File
@@ -7,6 +7,7 @@ export interface ICatalogOptions
groups?: HabboGroupEntryData[];
petPalettes?: CatalogPetPalette[];
clubOffers?: ClubOfferData[];
clubOffersByWindowId?: Record<number, ClubOfferData[]>;
clubGifts?: ClubGiftInfoParser;
giftConfiguration?: GiftWrappingConfiguration;
marketplaceConfiguration?: MarketplaceConfigurationMessageParser;
+3 -1
View File
@@ -42,6 +42,8 @@ export class FriendlyTime
public static getLocalization(key: string, amount: number): string
{
return LocalizeText(key, [ 'amount' ], [ amount.toString() ]);
const amountValue = amount.toString();
return LocalizeText(key, [ 'amount', 'AMOUNT' ], [ amountValue, amountValue ]);
}
}
+6 -4
View File
@@ -24,6 +24,8 @@ export interface BaseProps<T = HTMLElement> extends DetailedHTMLProps<HTMLAttrib
export const Base: FC<BaseProps<HTMLDivElement>> = props =>
{
const { ref = null, innerRef = null, display = null, fit = false, fitV = false, grow = false, shrink = false, fullWidth = false, fullHeight = false, overflow = null, position = null, float = null, pointer = false, visible = null, textColor = null, classNames = [], className = '', style = {}, children = null, ...rest } = props;
const safeClassNames = Array.isArray(classNames) ? classNames : [];
const safeClassName = (typeof className === 'string') ? className : '';
const getClassNames = useMemo(() =>
{
@@ -53,19 +55,19 @@ export const Base: FC<BaseProps<HTMLDivElement>> = props =>
if(textColor) newClassNames.push('text-' + textColor);
if(classNames.length) newClassNames.push(...classNames);
if(safeClassNames.length) newClassNames.push(...safeClassNames);
return newClassNames;
}, [ display, fit, fitV, grow, shrink, fullWidth, fullHeight, overflow, position, float, pointer, visible, textColor, classNames ]);
}, [ display, fit, fitV, grow, shrink, fullWidth, fullHeight, overflow, position, float, pointer, visible, textColor, safeClassNames ]);
const getClassName = useMemo(() =>
{
let newClassName = getClassNames.join(' ');
if(className.length) newClassName += (' ' + className);
if(safeClassName.length) newClassName += (' ' + safeClassName);
return newClassName.trim();
}, [ getClassNames, className ]);
}, [ getClassNames, safeClassName ]);
const getStyle = useMemo(() =>
{
+6 -4
View File
@@ -15,9 +15,7 @@ interface NitroCardHeaderViewProps extends ColumnProps
export const NitroCardHeaderView: FC<NitroCardHeaderViewProps> = props =>
{
const { headerText = null, isGalleryPhoto = false, noCloseButton = false, isInfoToHabboPages = false, onReportPhoto = null, onClickInfoHabboPages = null, onCloseClick = null, justifyContent = 'center', alignItems = 'center', classNames = [], children = null, ...rest } = props;
const { headerText = null, isGalleryPhoto = false, noCloseButton = false, isInfoToHabboPages = false, onReportPhoto = null, onClickInfoHabboPages = null, onCloseClick = null, justifyContent = 'center', alignItems = 'center', classNames = [], className = '', children = null, ...rest } = props;
const onMouseDown = (event: MouseEvent<HTMLDivElement>) =>
{
@@ -26,7 +24,11 @@ export const NitroCardHeaderView: FC<NitroCardHeaderViewProps> = props =>
};
return (
<Column center className={ 'nitro-card-header-shell relative flex items-center justify-center flex-col drag-handler min-h-card-header max-h-card-header' } { ...rest }>
<Column
center
classNames={ [ 'nitro-card-header-shell', 'relative', 'flex', 'items-center', 'justify-center', 'flex-col', 'drag-handler', 'min-h-card-header', 'max-h-card-header', ...classNames ] }
className={ className }
{ ...rest }>
<Flex center fullWidth>
<span className="nitro-card-title text-white">{ headerText }</span>
{ isGalleryPhoto &&
+23 -21
View File
@@ -1,13 +1,14 @@
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 { useMessageEvent, useNotification } from '../../hooks';
import { useCatalog, useMessageEvent, useNotification } from '../../hooks';
export interface IPageEditData
{
pageId?: number;
caption: string;
parentId: number;
catalogMode: string;
pageLayout: string;
enabled: string;
visible: string;
@@ -75,6 +76,7 @@ export const useCatalogAdmin = () => useContext(CatalogAdminContext);
export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children }) =>
{
const { currentType } = useCatalog();
const [ adminMode, setAdminMode ] = useState(false);
const [ editingOffer, setEditingOffer ] = useState<IPurchasableOffer | null>(null);
const [ editingPageData, setEditingPageData ] = useState(false);
@@ -175,9 +177,9 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children })
data.pageId || 0, data.caption, data.caption, data.pageLayout, 0,
data.minRank, data.visible === '1', data.enabled === '1',
data.orderNum, data.parentId,
data.pageHeadline || '', data.pageTeaser || '', data.pageTextDetails || ''
data.pageHeadline || '', data.pageTeaser || '', data.pageTextDetails || '', currentType, data.catalogMode
));
}, []);
}, [ currentType ]);
const createPage = useCallback((data: IPageEditData) =>
{
@@ -187,17 +189,17 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children })
SendMessageComposer(new CatalogAdminCreatePageComposer(
data.caption, data.caption, data.pageLayout, 0,
data.minRank, data.visible === '1', data.enabled === '1',
data.orderNum, data.parentId
data.orderNum, data.parentId, currentType, data.catalogMode
));
}, []);
}, [ currentType ]);
const deletePage = useCallback((pageId: number) =>
{
setLoading(true);
setLastError(null);
pendingActionRef.current = 'deletePage';
SendMessageComposer(new CatalogAdminDeletePageComposer(pageId));
}, []);
SendMessageComposer(new CatalogAdminDeletePageComposer(pageId, currentType));
}, [ currentType ]);
const saveOffer = useCallback((data: IOfferEditData) =>
{
@@ -208,9 +210,9 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children })
data.offerId || 0, data.pageId, parseInt(data.itemIds) || 0,
data.catalogName, data.costCredits, data.costPoints, data.pointsType,
data.amount, data.clubOnly === '1' ? 1 : 0, data.extradata,
data.haveOffer === '1', data.offerId_group, data.limitedStack, data.orderNumber
data.haveOffer === '1', data.offerId_group, data.limitedStack, data.orderNumber, currentType
));
}, []);
}, [ currentType ]);
const createOffer = useCallback((data: IOfferEditData) =>
{
@@ -221,17 +223,17 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children })
data.pageId, parseInt(data.itemIds) || 0,
data.catalogName, data.costCredits, data.costPoints, data.pointsType,
data.amount, data.clubOnly === '1' ? 1 : 0, data.extradata,
data.haveOffer === '1', data.offerId_group, data.limitedStack, data.orderNumber
data.haveOffer === '1', data.offerId_group, data.limitedStack, data.orderNumber, currentType
));
}, []);
}, [ currentType ]);
const deleteOffer = useCallback((offerId: number) =>
{
setLoading(true);
setLastError(null);
pendingActionRef.current = 'deleteOffer';
SendMessageComposer(new CatalogAdminDeleteOfferComposer(offerId));
}, []);
SendMessageComposer(new CatalogAdminDeleteOfferComposer(offerId, currentType));
}, [ currentType ]);
const reorderOffers = useCallback((orders: { id: number; orderNumber: number }[]) =>
{
@@ -241,33 +243,33 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children })
for(const order of orders)
{
SendMessageComposer(new CatalogAdminMoveOfferComposer(order.id, order.orderNumber));
SendMessageComposer(new CatalogAdminMoveOfferComposer(order.id, order.orderNumber, currentType));
}
}, []);
}, [ currentType ]);
const reorderPage = useCallback((pageId: number, newParentId: number, newIndex: number) =>
{
setLoading(true);
setLastError(null);
pendingActionRef.current = 'movePage';
SendMessageComposer(new CatalogAdminMovePageComposer(pageId, newParentId, newIndex));
}, []);
SendMessageComposer(new CatalogAdminMovePageComposer(pageId, newParentId, newIndex, currentType));
}, [ currentType ]);
const togglePageEnabled = useCallback((pageId: number) =>
{
setLoading(true);
setLastError(null);
pendingActionRef.current = 'toggleEnabled';
SendMessageComposer(new CatalogAdminMovePageComposer(pageId, -1, -1));
}, []);
SendMessageComposer(new CatalogAdminMovePageComposer(pageId, -1, -1, currentType));
}, [ currentType ]);
const togglePageVisible = useCallback((pageId: number) =>
{
setLoading(true);
setLastError(null);
pendingActionRef.current = 'toggleVisible';
SendMessageComposer(new CatalogAdminMovePageComposer(pageId, -2, -1));
}, []);
SendMessageComposer(new CatalogAdminMovePageComposer(pageId, -2, -1, currentType));
}, [ currentType ]);
const publishCatalog = useCallback(() =>
{
+38 -5
View File
@@ -1,12 +1,13 @@
import { AddLinkEventTracker, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useEffect } from 'react';
import { FaCog, FaEdit, FaEye, FaEyeSlash, FaPlus, FaTrash } from 'react-icons/fa';
import { GetConfigurationValue, LocalizeText } from '../../api';
import { CatalogType, GetConfigurationValue, LocalizeText } from '../../api';
import { Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common';
import { useCatalog } from '../../hooks';
import { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext';
import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView';
import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView';
import { CatalogBuildersClubStatusView } from './views/catalog-header/CatalogBuildersClubStatusView';
import { CatalogIconView } from './views/catalog-icon/CatalogIconView';
import { CatalogGiftView } from './views/gift/CatalogGiftView';
import { CatalogNavigationView } from './views/navigation/CatalogNavigationView';
@@ -15,7 +16,7 @@ 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 } = useCatalog();
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 catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
const setAdminMode = catalogAdmin?.setAdminMode ?? (() => {});
@@ -24,9 +25,26 @@ const CatalogClassicViewInner: FC<{}> = () =>
const loading = catalogAdmin?.loading ?? false;
const isMod = GetSessionDataManager().isModerator;
const buildersClubHeaderStyle = (currentType === CatalogType.BUILDER)
? { borderColor: '#d79d2e', borderBottomColor: '#000', background: 'linear-gradient(180deg, #d89f2d 0%, #c68515 100%)' }
: undefined;
useEffect(() =>
{
const getCatalogTypeFromLink = (type?: string) =>
{
switch((type || '').toLowerCase())
{
case 'bc':
case 'builder':
case 'buildersclub':
case 'builders_club':
return CatalogType.BUILDER;
default:
return CatalogType.NORMAL;
}
};
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) =>
{
@@ -37,12 +55,26 @@ const CatalogClassicViewInner: FC<{}> = () =>
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':
@@ -76,13 +108,13 @@ const CatalogClassicViewInner: FC<{}> = () =>
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, [ setIsVisible, openPageByOfferId, openPageByName ]);
}, [ setIsVisible, openPageByOfferId, openPageByName, openCatalogByType, toggleCatalogByType ]);
return (
<>
{ isVisible &&
<NitroCardView className="w-[630px] h-[400px]" style={ GetConfigurationValue('catalog.headers') ? { width: 710 } : {} } uniqueKey="catalog">
<NitroCardHeaderView headerText={ LocalizeText('catalog.title') } onCloseClick={ () => setIsVisible(false) } />
<NitroCardHeaderView className={ currentType === CatalogType.BUILDER ? 'builders-club-card-header' : '' } headerText={ LocalizeText('catalog.title') } onCloseClick={ () => setIsVisible(false) } style={ buildersClubHeaderStyle } />
{ /* Admin banner */ }
{ adminMode &&
<div className="flex items-center justify-between bg-warning text-dark text-[10px] font-bold px-3 py-0.5 uppercase tracking-wider" style={ { textShadow: '0 1px 0 rgba(255,255,255,0.3)' } }>
@@ -134,13 +166,14 @@ const CatalogClassicViewInner: FC<{}> = () =>
<FaCog className={ `text-[10px] ${ adminMode ? 'animate-spin' : '' }` } style={ adminMode ? { animationDuration: '3s' } : {} } />
</NitroCardTabsItemView> }
</NitroCardTabsView>
<CatalogBuildersClubStatusView />
<NitroCardContentView>
{ /* Admin: add new root category */ }
{ adminMode && rootNode &&
<div className="flex items-center gap-2 mb-1">
<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', pageLayout: 'default_3x3', minRank: 1, visible: '1', enabled: '1', orderNum: 99, parentId: rootNode.pageId }) }
onClick={ () => catalogAdmin.createPage({ caption: 'New Category', catalogMode: currentType, pageLayout: 'default_3x3', minRank: 1, visible: '1', enabled: '1', orderNum: 99, parentId: rootNode.pageId }) }
>
<FaPlus className="text-[8px]" />
<span>{ LocalizeText('catalog.admin.new') }</span>
+39 -6
View File
@@ -1,12 +1,13 @@
import { AddLinkEventTracker, GetSessionDataManager, 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 { LocalizeText } from '../../api';
import { CatalogType, LocalizeText } from '../../api';
import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common';
import { useCatalog, useCatalogFavorites } from '../../hooks';
import { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext';
import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView';
import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView';
import { CatalogBuildersClubStatusView } from './views/catalog-header/CatalogBuildersClubStatusView';
import { CatalogIconView } from './views/catalog-icon/CatalogIconView';
import { CatalogFavoritesView } from './views/favorites/CatalogFavoritesView';
import { CatalogGiftView } from './views/gift/CatalogGiftView';
@@ -17,7 +18,7 @@ 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 } = useCatalog();
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 catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
const setAdminMode = catalogAdmin?.setAdminMode ?? (() => {});
@@ -29,9 +30,26 @@ const CatalogModernViewInner: FC<{}> = () =>
const isMod = GetSessionDataManager().isModerator;
const totalFavs = favoriteOfferIds.length + favoritePageIds.length;
const buildersClubHeaderStyle = (currentType === CatalogType.BUILDER)
? { borderColor: '#d79d2e', borderBottomColor: '#000', background: 'linear-gradient(180deg, #d89f2d 0%, #c68515 100%)' }
: undefined;
useEffect(() =>
{
const getCatalogTypeFromLink = (type?: string) =>
{
switch((type || '').toLowerCase())
{
case 'bc':
case 'builder':
case 'buildersclub':
case 'builders_club':
return CatalogType.BUILDER;
default:
return CatalogType.NORMAL;
}
};
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) =>
{
@@ -42,12 +60,26 @@ const CatalogModernViewInner: FC<{}> = () =>
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':
@@ -81,13 +113,13 @@ const CatalogModernViewInner: FC<{}> = () =>
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, [ setIsVisible, openPageByOfferId, openPageByName ]);
}, [ setIsVisible, openPageByOfferId, openPageByName, openCatalogByType, toggleCatalogByType ]);
return (
<>
{ isVisible &&
<NitroCardView className="nitro-catalog w-[780px] h-[520px]" uniqueKey="catalog">
<NitroCardHeaderView headerText={ LocalizeText('catalog.title') } onCloseClick={ () => setIsVisible(false) } />
<NitroCardHeaderView className={ currentType === CatalogType.BUILDER ? 'builders-club-card-header' : '' } headerText={ LocalizeText('catalog.title') } onCloseClick={ () => setIsVisible(false) } style={ buildersClubHeaderStyle } />
<NitroCardContentView classNames={ [ 'p-0!', 'overflow-hidden!' ] }>
{ /* Admin banner */ }
{ adminMode &&
@@ -102,7 +134,8 @@ const CatalogModernViewInner: FC<{}> = () =>
</button>
</div> }
<div className="flex h-full">
<CatalogBuildersClubStatusView />
<div className="flex min-h-0 flex-1">
{ /* === LEFT SIDEBAR === */ }
<div className="group/rail flex flex-col w-[52px] hover:w-[175px] min-w-[52px] bg-card-grid-item border-r-2 border-card-grid-item-border py-1.5 gap-px overflow-y-auto overflow-x-hidden transition-[width] duration-200 ease-in-out">
@@ -129,7 +162,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', pageLayout: 'default_3x3', minRank: 1, visible: '1', enabled: '1', orderNum: 99, parentId: rootNode.pageId }) }
onClick={ () => catalogAdmin.createPage({ caption: 'New Category', catalogMode: currentType, pageLayout: 'default_3x3', minRank: 1, visible: '1', enabled: '1', orderNum: 99, parentId: rootNode.pageId }) }
>
<FaPlus className="text-[8px]" />
<span className="whitespace-nowrap">{ LocalizeText('catalog.admin.new') }</span>
@@ -1,6 +1,6 @@
import { FC, useEffect, useState } from 'react';
import { FaSave, FaSpinner, FaTimes, FaTrash } from 'react-icons/fa';
import { LocalizeText } from '../../../../api';
import { CatalogType, LocalizeText } from '../../../../api';
import { useCatalog } from '../../../../hooks';
import { IPageEditData, useCatalogAdmin } from '../../CatalogAdminContext';
@@ -8,15 +8,20 @@ const LAYOUT_OPTIONS = [
'default_3x3', 'frontpage4', 'pets', 'pets2', 'pets3',
'spaces_new', 'soundmachine', 'trophies', 'roomads',
'guild_frontpage', 'guild_forum', 'guild_custom_furni',
'vip_buy', 'marketplace', 'marketplace_own_items',
'vip_buy', 'builders_club_frontpage', 'builders_club_addons', 'builders_club_loyalty', 'marketplace', 'marketplace_own_items',
'recycler', 'recycler_info', 'recycler_prizes',
'info_loyalty', 'badge_display', 'bots', 'single_bundle',
'color_grouping', 'recent_purchases', 'custom_prefix'
];
const MODE_OPTIONS = [
{ value: CatalogType.NORMAL, label: 'Normale' },
{ value: 'BOTH', label: 'Entrambi' }
];
export const CatalogAdminPageEditView: FC<{}> = () =>
{
const { currentPage = null, activeNodes = [], rootNode = null } = useCatalog();
const { currentPage = null, activeNodes = [], rootNode = null, currentType = CatalogType.NORMAL } = useCatalog();
const catalogAdmin = useCatalogAdmin();
const editingPageData = catalogAdmin?.editingPageData ?? false;
const editingRootPage = catalogAdmin?.editingRootPage ?? false;
@@ -24,6 +29,7 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
const loading = catalogAdmin?.loading ?? false;
const [ caption, setCaption ] = useState('');
const [ catalogMode, setCatalogMode ] = useState(CatalogType.NORMAL);
const [ pageLayout, setPageLayout ] = useState('default_3x3');
const [ minRank, setMinRank ] = useState(1);
const [ visible, setVisible ] = useState('1');
@@ -55,12 +61,13 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
if(!editingPageData || !targetNode) return;
setCaption(targetNode.localization || '');
setCatalogMode(currentType === CatalogType.BUILDER ? CatalogType.BUILDER : (currentType || CatalogType.NORMAL));
setPageLayout(currentPage?.layoutCode || 'default_3x3');
setVisible(targetNode.isVisible ? '1' : '0');
setEnabled('1');
setMinRank(1);
setOrderNum(0);
}, [ editingPageData, targetNode, currentPage ]);
}, [ editingPageData, targetNode, currentPage, currentType ]);
if(!editingPageData || !targetNode) return null;
@@ -75,6 +82,7 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
const data: IPageEditData = {
pageId: targetPageId,
caption,
catalogMode,
pageLayout,
minRank,
visible,
@@ -116,6 +124,14 @@ 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">
<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> }
</div>
<div className="flex flex-col gap-0.5">
<label className="text-[9px] text-muted uppercase font-bold">Layout</label>
<select className={ inputClass } value={ pageLayout } onChange={ e => setPageLayout(e.target.value) }>
@@ -0,0 +1,86 @@
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';
export const CatalogBuildersClubStatusView: FC = () =>
{
const { currentType = CatalogType.NORMAL, furniCount = 0, furniLimit = 0, secondsLeft = 0, secondsLeftWithGrace = 0, updateTime = 0 } = useCatalog();
const [ ticker, setTicker ] = useState(() => GetTickerTime());
useEffect(() =>
{
if(currentType !== CatalogType.BUILDER) return;
const interval = window.setInterval(() => setTicker(GetTickerTime()), 1000);
return () => window.clearInterval(interval);
}, [ currentType ]);
const localizeOrDefault = (key: string, fallback: string, parameters: string[] = [], values: string[] = []) =>
{
const localized = LocalizeText(key, parameters, values);
return ((localized && (localized !== key)) ? localized : fallback);
};
const remainingSeconds = useMemo(() =>
{
const baseSeconds = (secondsLeft > 0) ? secondsLeft : secondsLeftWithGrace;
if(baseSeconds <= 0) return 0;
const elapsed = ((updateTime > 0) ? Math.floor((ticker - updateTime) / 1000) : 0);
return Math.max(0, (baseSeconds - elapsed));
}, [ secondsLeft, secondsLeftWithGrace, ticker, updateTime ]);
const isFullMember = (secondsLeft > 0);
const membershipStatus = localizeOrDefault(
isFullMember ? 'builder.header.status.member' : 'builder.header.status.trial',
isFullMember ? 'Membro Completo' : 'Prova Gratuita'
);
const title = localizeOrDefault(
'builder.header.title',
`Stato Builders' Club: ${ membershipStatus }`,
[ 'BCSTATUS' ],
[ membershipStatus ]
);
const durationText = localizeOrDefault(
'builder.header.status.membership',
`Tempo mancante: ${ FriendlyTime.format(remainingSeconds) }`,
[ 'DURATION' ],
[ FriendlyTime.format(remainingSeconds) ]
);
const limitText = localizeOrDefault(
'builder.header.status.limit',
`Furni usati: ${ furniCount }/${ furniLimit }`,
[ 'COUNT', 'LIMIT' ],
[ furniCount.toString(), furniLimit.toString() ]
);
if(currentType !== CatalogType.BUILDER) return null;
return (
<div className="builders-club-status-shell flex items-center gap-3 px-4 py-3">
<div className="builders-club-status-icon-shell flex h-[44px] w-[44px] shrink-0 items-center justify-center rounded-md">
<img alt="" className="h-[28px] w-[28px] object-contain" src={ buildersClubIcon } />
</div>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-[13px] leading-none font-bold text-white">
{ title }
</span>
<span className="mt-1 text-[11px] leading-tight text-white/95">
{ durationText }
</span>
<span className="text-[11px] leading-tight text-[#ffba45]">
{ limitText }
</span>
</div>
</div>
);
};
@@ -1,6 +1,6 @@
import { FC, useCallback, useRef, useState } from 'react';
import { FaArrowsAlt, FaCaretDown, FaCaretUp, FaPlus, FaStar, FaTrash } from 'react-icons/fa';
import { ICatalogNode, LocalizeText } from '../../../../api';
import { CatalogType, ICatalogNode, LocalizeText } from '../../../../api';
import { useCatalog, useCatalogFavorites } from '../../../../hooks';
import { useCatalogAdmin } from '../../CatalogAdminContext';
import { CatalogIconView } from '../catalog-icon/CatalogIconView';
@@ -15,7 +15,7 @@ export interface CatalogNavigationItemViewProps
export const CatalogNavigationItemView: FC<CatalogNavigationItemViewProps> = props =>
{
const { node = null, child = false } = props;
const { activateNode = null } = useCatalog();
const { activateNode = null, currentType = CatalogType.NORMAL } = useCatalog();
const catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
const { isFavoritePage, toggleFavoritePage } = useCatalogFavorites();
@@ -100,6 +100,7 @@ export const CatalogNavigationItemView: FC<CatalogNavigationItemViewProps> = pro
e.stopPropagation();
catalogAdmin.createPage({
caption: 'New Page',
catalogMode: currentType,
pageLayout: 'default_3x3',
minRank: 1,
visible: '1',
@@ -1,7 +1,7 @@
import { MouseEventType } from '@nitrots/nitro-renderer';
import { FC, MouseEvent, useMemo, useState } from 'react';
import { FaHeart } from 'react-icons/fa';
import { IPurchasableOffer, Offer, ProductTypeEnum } from '../../../../../api';
import { CatalogType, IPurchasableOffer, Offer, ProductTypeEnum } from '../../../../../api';
import { LayoutAvatarImageView, LayoutGridItem, LayoutGridItemProps } from '../../../../../common';
import { useCatalog, useCatalogFavorites, useInventoryFurni } from '../../../../../hooks';
@@ -15,7 +15,7 @@ export const CatalogGridOfferView: FC<CatalogGridOfferViewProps> = props =>
{
const { offer = null, selectOffer = null, itemActive = false, ...rest } = props;
const [ isMouseDown, setMouseDown ] = useState(false);
const { requestOfferToMover = null } = useCatalog();
const { requestOfferToMover = null, currentType = CatalogType.NORMAL } = useCatalog();
const { isVisible = false } = useInventoryFurni();
const { isFavoriteOffer, toggleFavoriteOffer } = useCatalogFavorites();
const isFav = offer ? isFavoriteOffer(offer.offerId) : false;
@@ -44,7 +44,9 @@ export const CatalogGridOfferView: FC<CatalogGridOfferViewProps> = props =>
setMouseDown(false);
return;
case MouseEventType.ROLL_OUT:
if(!isMouseDown || !itemActive || !isVisible) return;
if(!isMouseDown || !itemActive) return;
if(currentType === CatalogType.BUILDER) return;
if(!isVisible) return;
requestOfferToMover(offer);
return;
@@ -0,0 +1,209 @@
import { ClubOfferData, GetClubOffersMessageComposer, 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 { CatalogHeaderView } from '../../catalog-header/CatalogHeaderView';
import { CatalogLayoutProps } from './CatalogLayout.types';
const BUILDERS_CLUB_WINDOW_ID = 2;
const BUILDERS_CLUB_ADDONS_WINDOW_ID = 3;
export const CatalogLayoutBuildersClubBuyView: FC<CatalogLayoutProps> = () =>
{
const [ pendingOffer, setPendingOffer ] = useState<ClubOfferData>(null);
const [ purchaseState, setPurchaseState ] = useState(CatalogPurchaseState.NONE);
const { currentPage = null, catalogOptions = null } = useCatalog();
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 onCatalogEvent = useCallback((event: CatalogEvent) =>
{
switch(event.type)
{
case CatalogPurchasedEvent.PURCHASE_SUCCESS:
isPurchasingRef.current = false;
setPurchaseState(CatalogPurchaseState.NONE);
return;
case CatalogPurchaseFailureEvent.PURCHASE_FAILED:
isPurchasingRef.current = false;
setPurchaseState(CatalogPurchaseState.FAILED);
return;
}
}, []);
useUiEvent(CatalogPurchasedEvent.PURCHASE_SUCCESS, onCatalogEvent);
useUiEvent(CatalogPurchaseFailureEvent.PURCHASE_FAILED, onCatalogEvent);
const getOfferTotalUnits = useCallback((offer: ClubOfferData) =>
{
if(!offer) return 0;
return ((offer.months * 31) + offer.extraDays);
}, []);
const getOfferName = useCallback((offer: ClubOfferData) =>
{
if(!offer) return '';
const localized = LocalizeText(offer.productCode);
if(localized && (localized !== offer.productCode)) return localized;
return offer.productCode.replace(/_/g, ' ');
}, []);
const getOfferMeta = useCallback((offer: ClubOfferData) =>
{
if(!offer) return '';
if(isAddonLayout)
{
const units = getOfferTotalUnits(offer);
return (units > 0) ? `+${ units }` : '';
}
const parts: string[] = [];
if(offer.months > 0) parts.push(LocalizeText('catalog.vip.item.header.months', [ 'num_months' ], [ offer.months.toString() ]));
if(offer.extraDays > 0) parts.push(LocalizeText('catalog.vip.item.header.days', [ 'num_days' ], [ offer.extraDays.toString() ]));
return parts.join(' ');
}, [ getOfferTotalUnits, isAddonLayout ]);
const purchaseOffer = useCallback(() =>
{
if(!pendingOffer || !currentPage || isPurchasingRef.current) return;
isPurchasingRef.current = true;
setPurchaseState(CatalogPurchaseState.PURCHASE);
SendMessageComposer(new PurchaseFromCatalogComposer(currentPage.pageId, pendingOffer.offerId, null, 1));
}, [ pendingOffer, currentPage ]);
const getPurchaseButton = useCallback(() =>
{
if(!pendingOffer) return null;
if(pendingOffer.priceCredits > getCurrencyAmount(-1))
{
return <Button fullWidth variant="danger">{ LocalizeText('catalog.alert.notenough.title') }</Button>;
}
if(pendingOffer.priceActivityPoints > getCurrencyAmount(pendingOffer.priceActivityPointsType))
{
return <Button fullWidth variant="danger">{ LocalizeText(`catalog.alert.notenough.activitypoints.title.${ pendingOffer.priceActivityPointsType }`) }</Button>;
}
switch(purchaseState)
{
case CatalogPurchaseState.CONFIRM:
return <Button fullWidth variant="warning" onClick={ purchaseOffer }>{ 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>;
}
}, [ getCurrencyAmount, pendingOffer, purchaseOffer, purchaseState ]);
const pageDescription = useMemo(() =>
{
if(!currentPage) return '';
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;
setPendingOffer(prevValue =>
{
if(prevValue && offers.some(offer => (offer.offerId === prevValue.offerId))) return prevValue;
return offers[0];
});
}, [ offers ]);
return (
<div className="flex h-full flex-col gap-2">
{ 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);
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) &&
<Flex alignItems="center" gap={ 1 } justifyContent="end">
<Text>{ offer.priceActivityPoints }</Text>
<LayoutCurrencyIcon type={ offer.priceActivityPointsType } />
</Flex> }
</div>
</LayoutGridItem>
);
}) }
</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 fullWidth gap={ 1 }>
<Text fontWeight="bold">{ getOfferName(pendingOffer) }</Text>
{ getOfferMeta(pendingOffer).length > 0 && <Text>{ getOfferMeta(pendingOffer) }</Text> }
<Flex alignItems="end">
<Column grow gap={ 0 }>
<Text>{ currentPage?.localization.getText(0) || '' }</Text>
</Column>
<div className="flex flex-col gap-1">
{ (pendingOffer.priceCredits > 0) &&
<Flex alignItems="center" gap={ 1 } justifyContent="end">
<Text>{ pendingOffer.priceCredits }</Text>
<LayoutCurrencyIcon type={ -1 } />
</Flex> }
{ (pendingOffer.priceActivityPoints > 0) &&
<Flex alignItems="center" gap={ 1 } justifyContent="end">
<Text>{ pendingOffer.priceActivityPoints }</Text>
<LayoutCurrencyIcon type={ pendingOffer.priceActivityPointsType } />
</Flex> }
</div>
</Flex>
{ getPurchaseButton() }
</Column> }
</Column>
</Grid>
</div>
);
};
@@ -12,7 +12,8 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
const [ purchaseState, setPurchaseState ] = useState(CatalogPurchaseState.NONE);
const { currentPage = null, catalogOptions = null } = useCatalog();
const { purse = null, getCurrencyAmount = null } = usePurse();
const { clubOffers = null } = catalogOptions;
const { clubOffers = null, clubOffersByWindowId = null } = (catalogOptions || {});
const offers = clubOffersByWindowId?.[1] || clubOffers;
const isPurchasingRef = useRef<boolean>(false);
const onCatalogEvent = useCallback((event: CatalogEvent) =>
@@ -129,14 +130,14 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
useEffect(() =>
{
if(!clubOffers) SendMessageComposer(new GetClubOffersMessageComposer(1));
}, [ clubOffers ]);
if(!offers) SendMessageComposer(new GetClubOffersMessageComposer(1));
}, [ offers ]);
return (
<Grid>
<Column fullHeight justifyContent="between" overflow="hidden" size={ 7 }>
<AutoGrid className="nitro-catalog-layout-vip-buy-grid" columnCount={ 1 }>
{ clubOffers && (clubOffers.length > 0) && clubOffers.map((offer, index) =>
{ offers && (offers.length > 0) && offers.map((offer, index) =>
{
return (
<LayoutGridItem key={ index } alignItems="center" center={ false } className="p-1" column={ false } itemActive={ pendingOffer === offer } justifyContent="between" onClick={ () => setOffer(offer) }>
@@ -1,6 +1,7 @@
import { ICatalogPage } from '../../../../../api';
import { CatalogLayoutProps } from './CatalogLayout.types';
import { CatalogLayoutBadgeDisplayView } from './CatalogLayoutBadgeDisplayView';
import { CatalogLayoutBuildersClubBuyView } from './CatalogLayoutBuildersClubBuyView';
import { CatalogLayoutColorGroupingView } from './CatalogLayoutColorGroupingView';
import { CatalogLayoutCustomPrefixView } from './CatalogLayoutCustomPrefixView';
import { CatalogLayoutDefaultView } from './CatalogLayoutDefaultView';
@@ -43,6 +44,10 @@ export const GetCatalogLayout = (page: ICatalogPage, hideNavigation: () => void)
return <CatalogLayoutPets3View { ...layoutProps } />;
case 'vip_buy':
return <CatalogLayoutVipBuyView { ...layoutProps } />;
case 'builders_club_frontpage':
case 'builders_club_addons':
case 'builders_club_loyalty':
return <CatalogLayoutBuildersClubBuyView { ...layoutProps } />;
case 'guild_frontpage':
return <CatalogLayouGuildFrontpageView { ...layoutProps } />;
case 'guild_forum':
@@ -1,9 +1,9 @@
import { CreateLinkEvent, PurchaseFromCatalogComposer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { CatalogPurchaseState, DispatchUiEvent, GetClubMemberLevel, LocalStorageKeys, LocalizeText, Offer, SendMessageComposer } from '../../../../../api';
import { Button, LayoutLoadingSpinnerView } from '../../../../../common';
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, usePurse, useUiEvent } from '../../../../../hooks';
import { useCatalog, useLocalStorage, useNotification, usePurse, useUiEvent } from '../../../../../hooks';
interface CatalogPurchaseWidgetViewProps
{
@@ -16,11 +16,13 @@ let isPurchasingCatalogItem = false;
export const CatalogPurchaseWidgetView: FC<CatalogPurchaseWidgetViewProps> = props =>
{
const { noGiftOption = false, purchaseCallback = null } = props;
const [ builderPlaceableRefreshTick, setBuilderPlaceableRefreshTick ] = useState(0);
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, purchaseOptions = null, setPurchaseOptions = null } = useCatalog();
const { currentOffer = null, currentPage = null, currentType = CatalogType.NORMAL, purchaseOptions = null, setPurchaseOptions = null, requestOfferToMover = null, setCatalogPlaceMultipleObjects = null, getBuilderFurniPlaceableStatus = null } = useCatalog();
const { getCurrencyAmount = null } = usePurse();
const { showSingleBubble = null } = useNotification();
const onCatalogEvent = useCallback((event: CatalogEvent) =>
{
@@ -132,8 +134,79 @@ export const CatalogPurchaseWidgetView: FC<CatalogPurchaseWidgetViewProps> = pro
if(!currentOffer) return null;
const isBuildersClubOffer = (currentType === CatalogType.BUILDER);
const isBuildersClubPlaceable = isBuildersClubOffer
&& !!currentOffer.product
&& ((currentOffer.product.productType === ProductTypeEnum.FLOOR) || (currentOffer.product.productType === ProductTypeEnum.WALL));
const builderPlaceableStatus = useMemo(() =>
{
if(!isBuildersClubPlaceable || !getBuilderFurniPlaceableStatus) return BuilderFurniPlaceableStatus.OKAY;
return getBuilderFurniPlaceableStatus(currentOffer);
}, [ currentOffer, getBuilderFurniPlaceableStatus, isBuildersClubPlaceable, builderPlaceableRefreshTick ]);
const buildersClubPlaceOneButtonStyle = useMemo(() => ({
background: 'linear-gradient(180deg, #d89f2d 0%, #c68515 100%)',
borderColor: '#d79d2e',
color: '#ffffff'
}), []);
useEffect(() =>
{
if(!isBuildersClubPlaceable) return;
const interval = setInterval(() => setBuilderPlaceableRefreshTick(prevValue => (prevValue + 1)), 500);
return () => clearInterval(interval);
}, [ isBuildersClubPlaceable ]);
const PurchaseButton = () =>
{
if(isBuildersClubPlaceable)
{
const hasMissingExtraParam = (purchaseOptions.extraParamRequired && (!purchaseOptions.extraData || !purchaseOptions.extraData.length));
const isBlockedByVisitors = (builderPlaceableStatus === BuilderFurniPlaceableStatus.VISITORS_IN_ROOM);
const isDisabled = hasMissingExtraParam
|| isBlockedByVisitors
|| (builderPlaceableStatus === BuilderFurniPlaceableStatus.MISSING_OFFER)
|| (builderPlaceableStatus === BuilderFurniPlaceableStatus.NOT_IN_ROOM)
|| (builderPlaceableStatus === BuilderFurniPlaceableStatus.NOT_ROOM_OWNER)
|| (builderPlaceableStatus === BuilderFurniPlaceableStatus.NOT_GROUP_ADMIN);
const startBuilderPlacement = (placeMultiple: boolean) =>
{
if(builderPlaceableStatus === BuilderFurniPlaceableStatus.FURNI_LIMIT_REACHED)
{
showSingleBubble(LocalizeText('room.error.max_furniture'), NotificationBubbleType.INFO);
return;
}
if(isDisabled) return;
setCatalogPlaceMultipleObjects(placeMultiple);
requestOfferToMover(currentOffer);
};
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) }>
{ LocalizeText('builder.placement_widget.place_many') }
</Button>
<Button disabled={ isDisabled } onClick={ () => startBuilderPlacement(false) } style={ buildersClubPlaceOneButtonStyle }>
{ LocalizeText('builder.placement_widget.place_one') }
</Button>
</div>
{ isBlockedByVisitors &&
<Text className="max-w-full" small variant="danger">
{ LocalizeText('builder.placement_widget.error.visitors') }
</Text> }
{ (builderPlaceableStatus === BuilderFurniPlaceableStatus.NOT_GROUP_ADMIN) &&
<Text className="max-w-full" small variant="danger">
{ LocalizeText('builder.placement_widget.error.not_group_admin') }
</Text> }
</div>
);
}
const priceCredits = (currentOffer.priceInCredits * purchaseOptions.quantity);
const pricePoints = (currentOffer.priceInActivityPoints * purchaseOptions.quantity);
@@ -164,7 +237,7 @@ export const CatalogPurchaseWidgetView: FC<CatalogPurchaseWidgetViewProps> = pro
return (
<>
<PurchaseButton />
{ (!noGiftOption && !currentOffer.isRentOffer) &&
{ (!isBuildersClubOffer && !noGiftOption && !currentOffer.isRentOffer) &&
<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> }
@@ -96,7 +96,7 @@ export const FriendBarItemView: FC<{ friend: MessengerFriend }> = props => {
<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); }} />
{friend.followingAllowed &&
{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>
@@ -65,7 +65,7 @@ export const FriendsListGroupItemView: FC<{ friend: MessengerFriend, selected: b
<div className="flex items-center gap-1">
{ !isRelationshipOpen &&
<>
{ friend.followingAllowed &&
{ friend.online &&
<div className="nitro-friends-spritesheet icon-follow cursor-pointer" title={ LocalizeText('friendlist.tip.follow') } onClick={ clickFollowFriend } /> }
{ friend.online &&
<div className="nitro-friends-spritesheet icon-chat cursor-pointer" title={ LocalizeText('friendlist.tip.im') } onClick={ openMessengerChat } /> }
@@ -1,5 +1,5 @@
import { CrackableDataType, CreateLinkEvent, FurnitureFloorUpdateEvent, GetRoomEngine, GetSoundManager, GroupInformationComposer, GroupInformationEvent, NowPlayingEvent, RoomControllerLevel, RoomObjectCategory, RoomObjectOperationType, RoomObjectVariable, RoomWidgetEnumItemExtradataParameter, RoomWidgetFurniInfoUsagePolicyEnum, SetObjectDataMessageComposer, SongInfoReceivedEvent, StringDataType, UpdateFurniturePositionComposer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useState } from 'react';
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { FaCrosshairs, FaRulerVertical, FaTimes } from 'react-icons/fa';
import { GrFormNextLink, GrRotateLeft, GrRotateRight } from 'react-icons/gr';
import { AvatarInfoFurni, GetGroupInformation, LocalizeText, SendMessageComposer } from '../../../../../api';
@@ -45,6 +45,12 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
const [ itemLocation, setItemLocation ] = useState<{ x: number; y: number; z: number }>({ x: -1, y: -1, z: -1 });
const [ dropdownOpen, setDropdownOpen ] = useState(sessionStorage.getItem('dropdownOpen') === 'true');
const [ furniLocationZ, setFurniLocationZ ] = useState<number>(null);
const showOwnerProfileIcon = useMemo(() =>
{
const ownerName = (avatarInfo?.ownerName || '').trim().toLowerCase();
return !!avatarInfo && (avatarInfo.ownerId > 0) && (ownerName !== 'builders club');
}, [ avatarInfo ]);
const sendUpdate = useCallback((deltaX: number, deltaY: number, newZ: number = 0, deltaDirection: number = 0) =>
{
@@ -490,7 +496,7 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1">
<UserProfileIconView userId={ avatarInfo.ownerId } />
{ showOwnerProfileIcon && <UserProfileIconView userId={ avatarInfo.ownerId } /> }
<Text small wrap variant="white">
{ LocalizeText('furni.owner', [ 'name' ], [ avatarInfo.ownerName ]) }
</Text>
+8 -2
View File
@@ -140,7 +140,10 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
<ToolbarItemView icon="game" onClick={ () => CreateLinkEvent('games/toggle') } className="tb-icon" />
</motion.div> }
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="catalog" onClick={ () => CreateLinkEvent('catalog/toggle') } className="tb-icon" />
<ToolbarItemView icon="catalog" onClick={ () => CreateLinkEvent('catalog/toggle/normal') } className="tb-icon" />
</motion.div>
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="buildersclub" onClick={ () => CreateLinkEvent('catalog/toggle/builder') } className="tb-icon" />
</motion.div>
<motion.div variants={ itemVariants } className="relative">
<ToolbarItemView icon="inventory" onClick={ () => CreateLinkEvent('inventory/toggle') } className="tb-icon" />
@@ -227,7 +230,10 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
<ToolbarItemView icon="game" onClick={ () => CreateLinkEvent('games/toggle') } className="tb-icon" />
</motion.div> }
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="catalog" onClick={ () => CreateLinkEvent('catalog/toggle') } className="tb-icon" />
<ToolbarItemView icon="catalog" onClick={ () => CreateLinkEvent('catalog/toggle/normal') } className="tb-icon" />
</motion.div>
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="buildersclub" onClick={ () => CreateLinkEvent('catalog/toggle/builder') } className="tb-icon" />
</motion.div>
<motion.div variants={ itemVariants } className="relative">
<ToolbarItemView icon="inventory" onClick={ () => CreateLinkEvent('inventory/toggle') } className="tb-icon" />
+7
View File
@@ -36,6 +36,13 @@
height: 36px;
}
.nitro-icon.icon-buildersclub {
background-image: url("@/assets/images/toolbar/icons/buildersclub.png");
background-size: contain;
width: 38px;
height: 36px;
}
.nitro-icon.icon-game {
background-image: url("@/assets/images/toolbar/icons/game.png");
width: 44px;
+22
View File
@@ -200,6 +200,12 @@ body {
padding: 5px;
}
.nitro-card-header-shell.builders-club-card-header {
border-color: #d79d2e;
border-bottom-color: #000;
background: linear-gradient(180deg, #d89f2d 0%, #c68515 100%);
}
.nitro-card-title {
margin: 0 auto;
font-family: UbuntuCondensed, Ubuntu, sans-serif;
@@ -207,6 +213,18 @@ body {
line-height: 1;
}
.builders-club-status-shell {
border-top: 1px solid rgba(255, 255, 255, 0.14);
border-bottom: 2px solid #000;
background:
linear-gradient(180deg, rgba(84, 62, 46, 0.95) 0%, rgba(50, 38, 29, 0.97) 100%);
}
.builders-club-status-icon-shell {
background: rgba(255, 193, 70, 0.12);
border: 1px solid rgba(255, 193, 70, 0.26);
}
.nitro-card-close-button,
.ubuntu-close-button {
width: 20px;
@@ -377,6 +395,10 @@ body {
padding: 4px;
}
.builders-club-status-shell {
padding: 10px 12px;
}
.nitro-card-title {
font-size: 17px;
}
+135 -29
View File
@@ -1,4 +1,4 @@
import { BuildersClubFurniCountMessageEvent, BuildersClubPlaceRoomItemMessageComposer, BuildersClubPlaceWallItemMessageComposer, BuildersClubQueryFurniCountMessageComposer, BuildersClubSubscriptionStatusMessageEvent, CatalogPageMessageEvent, CatalogPagesListEvent, CatalogPublishedMessageEvent, ClubGiftInfoEvent, CreateLinkEvent, FrontPageItem, FurniturePlaceComposer, FurniturePlacePaintComposer, GetCatalogIndexComposer, GetCatalogPageComposer, GetClubGiftInfo, GetGiftWrappingConfigurationComposer, GetRoomEngine, GetTickerTime, GiftWrappingConfigurationEvent, GuildMembershipsMessageEvent, HabboClubOffersMessageEvent, LegacyDataType, LimitedEditionSoldOutEvent, MarketplaceMakeOfferResult, NodeData, ProductOfferEvent, PurchaseErrorMessageEvent, PurchaseFromCatalogComposer, PurchaseNotAllowedMessageEvent, PurchaseOKMessageEvent, RoomControllerLevel, RoomEngineObjectPlacedEvent, RoomObjectCategory, RoomObjectPlacementSource, RoomObjectType, RoomObjectVariable, RoomPreviewer, SellablePetPalettesMessageEvent, Vector3d } from '@nitrots/nitro-renderer';
import { BuildersClubFurniCountMessageEvent, BuildersClubPlaceRoomItemMessageComposer, BuildersClubPlaceWallItemMessageComposer, BuildersClubQueryFurniCountMessageComposer, BuildersClubSubscriptionStatusMessageEvent, CatalogPageMessageEvent, CatalogPagesListEvent, CatalogPublishedMessageEvent, ClubGiftInfoEvent, CreateLinkEvent, FrontPageItem, FurniturePlaceComposer, FurniturePlacePaintComposer, GetCatalogIndexComposer, GetCatalogPageComposer, GetClubGiftInfo, GetGiftWrappingConfigurationComposer, GetRoomEngine, GetSessionDataManager, GetTickerTime, GiftWrappingConfigurationEvent, GuildMembershipsMessageEvent, HabboClubOffersMessageEvent, LegacyDataType, LimitedEditionSoldOutEvent, MarketplaceMakeOfferResult, NodeData, ProductOfferEvent, PurchaseErrorMessageEvent, PurchaseFromCatalogComposer, PurchaseNotAllowedMessageEvent, PurchaseOKMessageEvent, RoomControllerLevel, RoomEngineObjectPlacedEvent, RoomObjectCategory, RoomObjectPlacementSource, RoomObjectType, RoomObjectVariable, RoomPreviewer, SellablePetPalettesMessageEvent, Vector3d } from '@nitrots/nitro-renderer';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useBetween } from 'use-between';
import { BuilderFurniPlaceableStatus, CatalogNode, CatalogPage, CatalogPetPalette, CatalogType, DispatchUiEvent, FurniCategory, GetFurnitureData, GetProductDataForLocalization, GetRoomSession, GiftWrappingConfiguration, ICatalogNode, ICatalogOptions, ICatalogPage, IPageLocalization, IProduct, IPurchasableOffer, IPurchaseOptions, LocalizeText, NotificationAlertType, Offer, PageLocalization, PlacedObjectPurchaseData, PlaySound, Product, ProductTypeEnum, RequestedPage, SearchResult, SendMessageComposer, SoundNames } from '../../api';
@@ -40,7 +40,10 @@ const useCatalogState = () =>
const [ secondsLeft, setSecondsLeft ] = useState(0);
const [ updateTime, setUpdateTime ] = useState(0);
const [ secondsLeftWithGrace, setSecondsLeftWithGrace ] = useState(0);
const { simpleAlert = null } = useNotification();
const [ builderPlacementBlockedByVisitors, setBuilderPlacementBlockedByVisitors ] = useState(false);
const [ builderPlacementAllowedInCurrentRoom, setBuilderPlacementAllowedInCurrentRoom ] = useState(false);
const [ builderTrialRoomHideConfirmed, setBuilderTrialRoomHideConfirmed ] = useState(false);
const { simpleAlert = null, showConfirm = null } = useNotification();
const requestedPage = useRef(new RequestedPage());
const resetState = useCallback(() =>
@@ -57,37 +60,108 @@ const useCatalogState = () =>
setIsVisible(false);
}, []);
const normalizeCatalogType = useCallback((type?: string) =>
{
if(type === CatalogType.BUILDER) return CatalogType.BUILDER;
return CatalogType.NORMAL;
}, []);
const resetVisibleCatalogState = useCallback((type?: string) =>
{
requestedPage.current.resetRequest();
setPageId(-1);
setPreviousPageId(-1);
setRootNode(null);
setOffersToNodes(null);
setCurrentPage(null);
setCurrentOffer(null);
setActiveNodes([]);
setSearchResult(null);
setFrontPageItems([]);
setNavigationHidden(false);
setCurrentType(normalizeCatalogType(type));
}, [ normalizeCatalogType ]);
const openCatalogByType = useCallback((type?: string) =>
{
const catalogType = normalizeCatalogType(type);
if(currentType !== catalogType)
{
resetVisibleCatalogState(catalogType);
}
setIsVisible(true);
}, [ currentType, normalizeCatalogType, resetVisibleCatalogState ]);
const toggleCatalogByType = useCallback((type?: string) =>
{
const catalogType = normalizeCatalogType(type);
if(isVisible && (currentType === catalogType))
{
setIsVisible(false);
return;
}
if(currentType !== catalogType)
{
resetVisibleCatalogState(catalogType);
}
setIsVisible(true);
}, [ isVisible, currentType, normalizeCatalogType, resetVisibleCatalogState ]);
const getBuilderFurniPlaceableStatus = useCallback((offer: IPurchasableOffer) =>
{
if(!offer) return BuilderFurniPlaceableStatus.MISSING_OFFER;
if((furniCount < 0) || (furniCount >= furniLimit)) return BuilderFurniPlaceableStatus.FURNI_LIMIT_REACHED;
const roomSession = GetRoomSession();
const canUseGuildAdminFallback = (!!roomSession
&& roomSession.isGuildRoom
&& (roomSession.controllerLevel >= RoomControllerLevel.GUILD_ADMIN)
&& (secondsLeft > 0));
const usesSharedPlacementPool = (!!roomSession && !roomSession.isRoomOwner && (builderPlacementAllowedInCurrentRoom || canUseGuildAdminFallback));
if(!roomSession) return BuilderFurniPlaceableStatus.NOT_IN_ROOM;
if(!roomSession.isRoomOwner) return BuilderFurniPlaceableStatus.NOT_ROOM_OWNER;
if(!roomSession.isRoomOwner && !builderPlacementAllowedInCurrentRoom && !canUseGuildAdminFallback) return BuilderFurniPlaceableStatus.NOT_GROUP_ADMIN;
if(!usesSharedPlacementPool && ((furniCount < 0) || (furniCount >= furniLimit))) return BuilderFurniPlaceableStatus.FURNI_LIMIT_REACHED;
if((secondsLeft <= 0) && builderPlacementBlockedByVisitors) return BuilderFurniPlaceableStatus.VISITORS_IN_ROOM;
if(secondsLeft <= 0)
{
const roomEngine = GetRoomEngine();
const userDataManager = roomSession.userDataManager;
const sessionDataManager = GetSessionDataManager();
let objectCount = roomEngine.getRoomObjectCount(roomSession.roomId, RoomObjectCategory.UNIT);
if(!roomEngine || !userDataManager || !sessionDataManager) return BuilderFurniPlaceableStatus.OKAY;
while(objectCount > 0)
const roomObjects = roomEngine.getRoomObjects(roomSession.roomId, RoomObjectCategory.UNIT);
if(!roomObjects || !roomObjects.length) return BuilderFurniPlaceableStatus.OKAY;
for(const roomObject of roomObjects)
{
const roomObject = roomEngine.getRoomObjectByIndex(roomSession.roomId, objectCount, RoomObjectCategory.UNIT);
const userData = roomSession.userDataManager.getUserDataByIndex(roomObject.id);
if(!roomObject) continue;
if(userData && (userData.type === RoomObjectType.USER) && (userData.roomIndex !== roomSession.ownRoomIndex) && !userData.isModerator) return BuilderFurniPlaceableStatus.VISITORS_IN_ROOM;
const userData = userDataManager.getUserDataByIndex(roomObject.id);
objectCount--;
if(!userData || (userData.type !== RoomObjectType.USER)) continue;
if(userData.webID === sessionDataManager.userId) continue;
if(userData.isModerator) continue;
return BuilderFurniPlaceableStatus.VISITORS_IN_ROOM;
}
}
return BuilderFurniPlaceableStatus.OKAY;
}, [ furniCount, furniLimit, secondsLeft ]);
}, [ builderPlacementAllowedInCurrentRoom, builderPlacementBlockedByVisitors, furniCount, furniLimit, secondsLeft ]);
const isDraggable = useCallback((offer: IPurchasableOffer) =>
{
@@ -419,6 +493,10 @@ const useCatalogState = () =>
useMessageEvent<CatalogPagesListEvent>(CatalogPagesListEvent, event =>
{
const parser = event.getParser();
const parserCatalogType = normalizeCatalogType(parser.catalogType);
if(parserCatalogType !== currentType) return;
const offers: Map<number, ICatalogNode[]> = new Map();
const getCatalogNode = (node: NodeData, depth: number, parent: ICatalogNode) =>
@@ -589,9 +667,14 @@ const useCatalogState = () =>
setCatalogOptions(prevValue =>
{
const clubOffers = parser.offers;
const windowId = parser.windowId;
const clubOffersByWindowId = { ...(prevValue.clubOffersByWindowId || {}) };
return { ...prevValue, clubOffers };
clubOffersByWindowId[windowId] = parser.offers;
const clubOffers = clubOffersByWindowId[1] || prevValue.clubOffers;
return { ...prevValue, clubOffers, clubOffersByWindowId };
});
});
@@ -679,6 +762,8 @@ const useCatalogState = () =>
setSecondsLeft(parser.secondsLeft);
setUpdateTime(GetTickerTime());
setSecondsLeftWithGrace(parser.secondsLeftWithGrace);
setBuilderPlacementBlockedByVisitors(parser.placementBlockedByVisitors);
setBuilderPlacementAllowedInCurrentRoom(parser.placementAllowedInCurrentRoom);
refreshBuilderStatus();
});
@@ -772,24 +857,40 @@ const useCatalogState = () =>
break;
}
case CatalogType.BUILDER: {
let pageId = purchasableOffer.page.pageId;
if(pageId === DUMMY_PAGE_ID_FOR_OFFER_SEARCH)
const placeBuilderItem = () =>
{
pageId = -1;
}
let pageId = purchasableOffer.page.pageId;
switch(event.category)
if(pageId === DUMMY_PAGE_ID_FOR_OFFER_SEARCH)
{
pageId = -1;
}
switch(event.category)
{
case RoomObjectCategory.FLOOR:
SendMessageComposer(new BuildersClubPlaceRoomItemMessageComposer(pageId, purchasableOffer.offerId, product.extraParam, event.x, event.y, event.direction));
break;
case RoomObjectCategory.WALL:
SendMessageComposer(new BuildersClubPlaceWallItemMessageComposer(pageId, purchasableOffer.offerId, product.extraParam, event.wallLocation));
break;
}
if(catalogPlaceMultipleObjects && ((furniCount + 1) < furniLimit)) requestOfferToMover(purchasableOffer);
};
if((secondsLeft <= 0) && (furniCount <= 0) && !builderTrialRoomHideConfirmed && showConfirm)
{
case RoomObjectCategory.FLOOR:
SendMessageComposer(new BuildersClubPlaceRoomItemMessageComposer(pageId, purchasableOffer.offerId, product.extraParam, event.x, event.y, event.direction));
break;
case RoomObjectCategory.WALL:
SendMessageComposer(new BuildersClubPlaceWallItemMessageComposer(pageId, purchasableOffer.offerId, product.extraParam, event.wallLocation));
break;
showConfirm(LocalizeText('room.confirm.hide_room'), () =>
{
setBuilderTrialRoomHideConfirmed(true);
placeBuilderItem();
}, () => resetPlacedOfferData());
}
else
{
placeBuilderItem();
}
if(catalogPlaceMultipleObjects) requestOfferToMover(purchasableOffer);
break;
}
}
@@ -882,6 +983,11 @@ const useCatalogState = () =>
setPurchaseOptions({ quantity: 1, extraData: null, extraParamRequired: false, previewStuffData: null });
}, [ currentOffer ]);
useEffect(() =>
{
if(secondsLeft > 0) setBuilderTrialRoomHideConfirmed(false);
}, [ secondsLeft ]);
useEffect(() =>
{
if(!isVisible || rootNode) return;
@@ -907,7 +1013,7 @@ const useCatalogState = () =>
};
}, []);
return { isVisible, setIsVisible, isBusy, pageId, previousPageId, currentType, rootNode, offersToNodes, currentPage, setCurrentPage, currentOffer, setCurrentOffer, activeNodes, searchResult, setSearchResult, frontPageItems, roomPreviewer, navigationHidden, setNavigationHidden, purchaseOptions, setPurchaseOptions, catalogOptions, setCatalogOptions, getNodeById, getNodeByName, activateNode, openPageById, openPageByName, openPageByOfferId, requestOfferToMover };
return { isVisible, setIsVisible, isBusy, pageId, previousPageId, currentType, rootNode, offersToNodes, currentPage, setCurrentPage, currentOffer, setCurrentOffer, activeNodes, searchResult, setSearchResult, frontPageItems, roomPreviewer, navigationHidden, setNavigationHidden, purchaseOptions, setPurchaseOptions, catalogOptions, setCatalogOptions, getNodeById, getNodeByName, activateNode, openPageById, openPageByName, openPageByOfferId, requestOfferToMover, openCatalogByType, toggleCatalogByType, furniCount, furniLimit, maxFurniLimit, secondsLeft, secondsLeftWithGrace, updateTime, catalogPlaceMultipleObjects, setCatalogPlaceMultipleObjects, getBuilderFurniPlaceableStatus };
};
export const useCatalog = () => useBetween(useCatalogState);
+107 -31
View File
@@ -1,5 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import { useBetween } from 'use-between';
import { CatalogType } from '../../api';
import { useCatalog } from './useCatalog';
export interface IFavoriteOffer
{
@@ -8,15 +10,22 @@ export interface IFavoriteOffer
iconUrl?: string;
}
const STORAGE_KEY_OFFERS = 'catalog_fav_offers_v2';
const STORAGE_KEY_PAGES = 'catalog_fav_pages';
const LEGACY_STORAGE_KEY_OFFERS = 'catalog_fav_offers_v2';
const LEGACY_STORAGE_KEY_PAGES = 'catalog_fav_pages';
const STORAGE_KEY_OFFERS_NORMAL = 'catalog_fav_offers_v3_normal';
const STORAGE_KEY_OFFERS_BUILDER = 'catalog_fav_offers_v3_builder';
const STORAGE_KEY_PAGES_NORMAL = 'catalog_fav_pages_v2_normal';
const STORAGE_KEY_PAGES_BUILDER = 'catalog_fav_pages_v2_builder';
const readOffers = (): IFavoriteOffer[] =>
const normalizeCatalogType = (catalogType?: string) => ((catalogType === CatalogType.BUILDER) ? CatalogType.BUILDER : CatalogType.NORMAL);
const getOffersStorageKey = (catalogType?: string) => ((normalizeCatalogType(catalogType) === CatalogType.BUILDER) ? STORAGE_KEY_OFFERS_BUILDER : STORAGE_KEY_OFFERS_NORMAL);
const getPagesStorageKey = (catalogType?: string) => ((normalizeCatalogType(catalogType) === CatalogType.BUILDER) ? STORAGE_KEY_PAGES_BUILDER : STORAGE_KEY_PAGES_NORMAL);
const parseOffers = (raw: string): IFavoriteOffer[] =>
{
try
{
const raw = localStorage.getItem(STORAGE_KEY_OFFERS);
if(!raw) return [];
const parsed = JSON.parse(raw);
if(!Array.isArray(parsed)) return [];
@@ -34,12 +43,10 @@ const readOffers = (): IFavoriteOffer[] =>
}
};
const readPages = (): number[] =>
const parsePages = (raw: string): number[] =>
{
try
{
const raw = localStorage.getItem(STORAGE_KEY_PAGES);
if(!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
}
@@ -49,28 +56,92 @@ const readPages = (): number[] =>
}
};
const writeOffers = (offers: IFavoriteOffer[]) =>
const readOffers = (catalogType?: string): IFavoriteOffer[] =>
{
localStorage.setItem(STORAGE_KEY_OFFERS, JSON.stringify(offers));
const storageKey = getOffersStorageKey(catalogType);
const raw = localStorage.getItem(storageKey);
if(raw) return parseOffers(raw);
if(normalizeCatalogType(catalogType) === CatalogType.NORMAL)
{
const legacyRaw = localStorage.getItem(LEGACY_STORAGE_KEY_OFFERS);
if(legacyRaw)
{
const migrated = parseOffers(legacyRaw);
localStorage.setItem(storageKey, JSON.stringify(migrated));
return migrated;
}
}
return [];
};
const writePages = (ids: number[]) =>
const readPages = (catalogType?: string): number[] =>
{
localStorage.setItem(STORAGE_KEY_PAGES, JSON.stringify(ids));
const storageKey = getPagesStorageKey(catalogType);
const raw = localStorage.getItem(storageKey);
if(raw) return parsePages(raw);
if(normalizeCatalogType(catalogType) === CatalogType.NORMAL)
{
const legacyRaw = localStorage.getItem(LEGACY_STORAGE_KEY_PAGES);
if(legacyRaw)
{
const migrated = parsePages(legacyRaw);
localStorage.setItem(storageKey, JSON.stringify(migrated));
return migrated;
}
}
return [];
};
const writeOffers = (catalogType: string, offers: IFavoriteOffer[]) =>
{
localStorage.setItem(getOffersStorageKey(catalogType), JSON.stringify(offers));
};
const writePages = (catalogType: string, ids: number[]) =>
{
localStorage.setItem(getPagesStorageKey(catalogType), JSON.stringify(ids));
};
const useCatalogFavoritesState = () =>
{
const [ favoriteOffers, setFavoriteOffers ] = useState<IFavoriteOffer[]>([]);
const [ favoritePageIds, setFavoritePageIds ] = useState<number[]>([]);
const { currentType = CatalogType.NORMAL } = useCatalog();
const catalogType = normalizeCatalogType(currentType);
const [ favoriteOffersByType, setFavoriteOffersByType ] = useState<Record<string, IFavoriteOffer[]>>({
[CatalogType.NORMAL]: [],
[CatalogType.BUILDER]: []
});
const [ favoritePageIdsByType, setFavoritePageIdsByType ] = useState<Record<string, number[]>>({
[CatalogType.NORMAL]: [],
[CatalogType.BUILDER]: []
});
const [ loaded, setLoaded ] = useState(false);
const favoriteOffers = favoriteOffersByType[catalogType] || [];
const favoritePageIds = favoritePageIdsByType[catalogType] || [];
const favoriteOfferIds = favoriteOffers.map(f => f.offerId);
const loadFavorites = useCallback(() =>
{
setFavoriteOffers(readOffers());
setFavoritePageIds(readPages());
setFavoriteOffersByType({
[CatalogType.NORMAL]: readOffers(CatalogType.NORMAL),
[CatalogType.BUILDER]: readOffers(CatalogType.BUILDER)
});
setFavoritePageIdsByType({
[CatalogType.NORMAL]: readPages(CatalogType.NORMAL),
[CatalogType.BUILDER]: readPages(CatalogType.BUILDER)
});
setLoaded(true);
}, []);
@@ -81,32 +152,37 @@ const useCatalogFavoritesState = () =>
const toggleFavoriteOffer = useCallback((offerId: number, name?: string, iconUrl?: string) =>
{
setFavoriteOffers(prev =>
setFavoriteOffersByType(prev =>
{
const exists = prev.find(f => f.offerId === offerId);
const currentOffers = prev[catalogType] || [];
const exists = currentOffers.find(f => f.offerId === offerId);
if(exists)
{
const next = prev.filter(f => f.offerId !== offerId);
writeOffers(next);
return next;
const next = currentOffers.filter(f => f.offerId !== offerId);
writeOffers(catalogType, next);
return { ...prev, [catalogType]: next };
}
const next = [ ...prev, { offerId, name, iconUrl } ];
writeOffers(next);
return next;
const next = [ ...currentOffers, { offerId, name, iconUrl } ];
writeOffers(catalogType, next);
return { ...prev, [catalogType]: next };
});
}, []);
}, [ catalogType ]);
const toggleFavoritePage = useCallback((pageId: number) =>
{
setFavoritePageIds(prev =>
setFavoritePageIdsByType(prev =>
{
const next = prev.includes(pageId) ? prev.filter(id => id !== pageId) : [ ...prev, pageId ];
writePages(next);
return next;
const currentPages = prev[catalogType] || [];
const next = currentPages.includes(pageId) ? currentPages.filter(id => id !== pageId) : [ ...currentPages, pageId ];
writePages(catalogType, next);
return { ...prev, [catalogType]: next };
});
}, []);
}, [ catalogType ]);
const isFavoriteOffer = useCallback((offerId: number) =>
{
@@ -123,7 +199,7 @@ const useCatalogFavoritesState = () =>
return favoriteOffers.find(f => f.offerId === offerId);
}, [ favoriteOffers ]);
return { favoriteOffers, favoriteOfferIds, favoritePageIds, loaded, loadFavorites, toggleFavoriteOffer, toggleFavoritePage, isFavoriteOffer, isFavoritePage, getFavoriteOffer };
return { favoriteOffers, favoriteOfferIds, favoritePageIds, loaded, loadFavorites, toggleFavoriteOffer, toggleFavoritePage, isFavoriteOffer, isFavoritePage, getFavoriteOffer, catalogType };
};
export const useCatalogFavorites = () => useBetween(useCatalogFavoritesState);
+12 -6
View File
@@ -1,8 +1,9 @@
import { AcceptFriendMessageComposer, DeclineFriendMessageComposer, FollowFriendMessageComposer, FriendListFragmentEvent, FriendListUpdateComposer, FriendListUpdateEvent, FriendParser, FriendRequestsEvent, GetFriendRequestsComposer, GetSessionDataManager, MessengerInitComposer, MessengerInitEvent, NewFriendRequestEvent, RequestFriendComposer, SetRelationshipStatusComposer } from '@nitrots/nitro-renderer';
import { AcceptFriendMessageComposer, DeclineFriendMessageComposer, FollowFriendFailedEvent, FollowFriendMessageComposer, FriendListFragmentEvent, FriendListUpdateComposer, FriendListUpdateEvent, FriendParser, FriendRequestsEvent, GetFriendRequestsComposer, GetSessionDataManager, MessengerInitComposer, MessengerInitEvent, NewFriendRequestEvent, RequestFriendComposer, SetRelationshipStatusComposer } from '@nitrots/nitro-renderer';
import { useEffect, useMemo, useState } from 'react';
import { useBetween } from 'use-between';
import { CloneObject, MessengerFriend, MessengerRequest, MessengerSettings, SendMessageComposer } from '../../api';
import { CloneObject, LocalizeText, MessengerFriend, MessengerRequest, MessengerSettings, NotificationAlertType, SendMessageComposer } from '../../api';
import { useMessageEvent } from '../events';
import { useNotification } from '../notification';
const useFriendsState = () =>
{
@@ -11,6 +12,7 @@ const useFriendsState = () =>
const [ sentRequests, setSentRequests ] = useState<number[]>([]);
const [ dismissedRequestIds, setDismissedRequestIds ] = useState<number[]>([]);
const [ settings, setSettings ] = useState<MessengerSettings>(null);
const { simpleAlert = null } = useNotification();
const onlineFriends = useMemo(() =>
{
@@ -152,17 +154,16 @@ const useFriendsState = () =>
const processUpdate = (friend: FriendParser) =>
{
const index = newValue.findIndex(existingFriend => (existingFriend.id === friend.id));
const newFriend = new MessengerFriend();
newFriend.populate(friend);
if(index === -1)
{
const newFriend = new MessengerFriend();
newFriend.populate(friend);
newValue.unshift(newFriend);
}
else
{
newValue[index].populate(friend);
newValue[index] = newFriend;
}
};
@@ -211,6 +212,11 @@ const useFriendsState = () =>
});
});
useMessageEvent<FollowFriendFailedEvent>(FollowFriendFailedEvent, () =>
{
simpleAlert(LocalizeText('friendlist.followerror.hotelview'), NotificationAlertType.DEFAULT, null, null, LocalizeText('friendlist.alert.title'));
});
useMessageEvent<NewFriendRequestEvent>(NewFriendRequestEvent, event =>
{
const parser = event.getParser();
+11 -1
View File
@@ -73,7 +73,17 @@ const useNotificationState = () =>
const notificationItem = new NotificationBubbleItem(message, type, imageUrl, internalLink);
setBubbleAlerts(prevValue => [ notificationItem, ...prevValue ]);
setBubbleAlerts(prevValue =>
{
if(type === NotificationBubbleType.CLUBGIFT)
{
const filteredAlerts = prevValue.filter(value => (value.notificationType !== NotificationBubbleType.CLUBGIFT));
return [ notificationItem, ...filteredAlerts ];
}
return [ notificationItem, ...prevValue ];
});
}, [ bubblesDisabled ]);
const showNotification = (type: string, options: Map<string, string> = null) =>