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
+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);