+ { /* Admin: quick actions */ }
+ { adminMode && !catalogAdmin.editingPageData &&
+
+
+
+
}
+
+ { /* Top card: preview + name + purchase */ }
+
+ { /* Pet preview */ }
+
+
+
+ { ((petIndex > -1) && (petIndex <= 7)) &&
+ }
+
+
+ { /* Pet info */ }
+
+
+
+
+ { petBreedName || LocalizeText('catalog.pet.breed') }
+ { adminMode && currentOffer &&
+ catalogAdmin.setEditingOffer(currentOffer) }
+ /> }
+
+ { adminMode && currentOffer &&
+
+ ID: { currentOffer.product.productClassId }
+ Offer: { currentOffer.offerId }
+
}
+ { !!page.localization.getText(0) &&
+
}
+
+
+ { /* Name input */ }
+
+
+
+ 0 ? 'border-danger bg-danger/5' : approvalResult === 0 ? 'border-success bg-success/5' : 'border-card-grid-item-border focus:border-primary bg-white' }` }
+ placeholder={ LocalizeText('widgets.petpackage.name.title') }
+ type="text"
+ value={ petName }
+ onChange={ event => setPetName(event.target.value) }
+ />
+ { approvalResult === 0 &&
+ }
+ { approvalResult > 0 &&
+ }
+
+ { (approvalResult > 0) &&
+
{ validationErrorMessage } }
+
+
+ { /* Price + buy */ }
+
+
+
+
+
+
+
+ { /* Breed/Color grid */ }
+
+
+
+ { colorsShowing ? LocalizeText('catalog.pets.choose.color') : LocalizeText('catalog.pets.choose.breed') }
+
+ { colorsShowing &&
+ }
+
+
+ { !colorsShowing && (sellablePalettes.length > 0) && sellablePalettes.map((palette, index) => (
+
setSelectedPaletteIndex(index) }
+ >
+
+
+ )) }
+ { colorsShowing && (sellableColors.length > 0) && sellableColors.map((colorSet, index) => (
+
setSelectedColorIndex(index) }
+ />
+ )) }
+
+
+
+ );
+};
diff --git a/src/components/catalog-modern/views/page/layout/vip-gifts/CatalogLayoutVipGiftsView.tsx b/src/components/catalog-modern/views/page/layout/vip-gifts/CatalogLayoutVipGiftsView.tsx
new file mode 100644
index 0000000..2614b13
--- /dev/null
+++ b/src/components/catalog-modern/views/page/layout/vip-gifts/CatalogLayoutVipGiftsView.tsx
@@ -0,0 +1,67 @@
+import { SelectClubGiftComposer } from '@nitrots/nitro-renderer';
+import { FC, useCallback, useMemo } from 'react';
+import { LocalizeText, SendMessageComposer } from '../../../../../../api';
+import { AutoGrid, Text } from '../../../../../../common';
+import { useClubGifts, useNotification, usePurse } from '../../../../../../hooks';
+import { CatalogLayoutProps } from '../CatalogLayout.types';
+import { VipGiftItem } from './VipGiftItemView';
+
+let isSelectingGift = false;
+
+export const CatalogLayoutVipGiftsView: FC
= props =>
+{
+ const { purse = null } = usePurse();
+ const { data: clubGifts = null } = useClubGifts();
+ const { showConfirm = null } = useNotification();
+
+ const giftsAvailable = useCallback(() =>
+ {
+ if(!clubGifts) return '';
+
+ if(clubGifts.giftsAvailable > 0) return LocalizeText('catalog.club_gift.available', [ 'amount' ], [ clubGifts.giftsAvailable.toString() ]);
+
+ if(clubGifts.daysUntilNextGift > 0) return LocalizeText('catalog.club_gift.days_until_next', [ 'days' ], [ clubGifts.daysUntilNextGift.toString() ]);
+
+ if(purse.isVip) return LocalizeText('catalog.club_gift.not_available');
+
+ return LocalizeText('catalog.club_gift.no_club');
+ }, [ clubGifts, purse ]);
+
+ const selectGift = useCallback((localizationId: string) =>
+ {
+ showConfirm(LocalizeText('catalog.club_gift.confirm'), () =>
+ {
+ if(isSelectingGift) return;
+
+ isSelectingGift = true;
+
+ // The server replies with a fresh ClubGiftInfoEvent after
+ // accepting the selection; useClubGifts subscribes to that
+ // event via useNitroEventInvalidator, so giftsAvailable
+ // refreshes from the authoritative source — no need to
+ // mutate the parser locally.
+ SendMessageComposer(new SelectClubGiftComposer(localizationId));
+
+ setTimeout(() => isSelectingGift = false, 5000);
+ }, null);
+ }, [ showConfirm ]);
+
+ const sortGifts = useMemo(() =>
+ {
+ if(!clubGifts) return [];
+
+ return [ ...clubGifts.offers ].sort((a, b) =>
+ (clubGifts.getOfferExtraData(a.offerId).daysRequired - clubGifts.getOfferExtraData(b.offerId).daysRequired)
+ );
+ }, [ clubGifts ]);
+
+
+ return (
+ <>
+ { giftsAvailable() }
+
+ { clubGifts && (clubGifts.offers.length > 0) && sortGifts.map(offer => 0)) } offer={ offer } onSelect={ selectGift }/>) }
+
+ >
+ );
+};
diff --git a/src/components/catalog-modern/views/page/layout/vip-gifts/VipGiftItemView.tsx b/src/components/catalog-modern/views/page/layout/vip-gifts/VipGiftItemView.tsx
new file mode 100644
index 0000000..4ca0c41
--- /dev/null
+++ b/src/components/catalog-modern/views/page/layout/vip-gifts/VipGiftItemView.tsx
@@ -0,0 +1,63 @@
+import { CatalogPageMessageOfferData } from '@nitrots/nitro-renderer';
+import { FC, useCallback } from 'react';
+import { LocalizeText, ProductImageUtility } from '../../../../../../api';
+import { Button, LayoutGridItem, LayoutImage, Text } from '../../../../../../common';
+
+export interface VipGiftItemViewProps
+{
+ offer: CatalogPageMessageOfferData;
+ isAvailable: boolean;
+ daysRequired: number;
+ onSelect(localizationId: string): void;
+}
+
+export const VipGiftItem : FC = props =>
+{
+ const { offer = null, isAvailable = false, daysRequired = 0, onSelect = null } = props;
+
+ const getImageUrlForOffer = useCallback( () =>
+ {
+ if(!offer || !offer.products.length) return '';
+
+ const productData = offer.products[0];
+
+ return ProductImageUtility.getProductImageUrl(productData.productType, productData.furniClassId, productData.extraParam);
+ }, [ offer ]);
+
+ const getItemTitle = useCallback(() =>
+ {
+ if(!offer || !offer.products.length) return '';
+
+ const productData = offer.products[0];
+
+ const localizationKey = ProductImageUtility.getProductCategory(productData.productType, productData.furniClassId) === 2 ? 'wallItem.name.' + productData.furniClassId : 'roomItem.name.' + productData.furniClassId;
+
+ return LocalizeText(localizationKey);
+ }, [ offer ]);
+
+ const getItemDesc = useCallback( () =>
+ {
+ if(!offer || !offer.products.length) return '';
+
+ const productData = offer.products[0];
+
+ const localizationKey = ProductImageUtility.getProductCategory(productData.productType, productData.furniClassId) === 2 ? 'wallItem.desc.' + productData.furniClassId : 'roomItem.desc.' + productData.furniClassId ;
+
+ return LocalizeText(localizationKey);
+ }, [ offer ]);
+
+ const getMonthsRequired = useCallback(() =>
+ {
+ return Math.floor(daysRequired / 31);
+ },[ daysRequired ]);
+
+ return (
+
+
+ { getItemTitle() }
+
+
+ );
+};
diff --git a/src/components/catalog-modern/views/page/widgets/CatalogAddOnBadgeWidgetView.tsx b/src/components/catalog-modern/views/page/widgets/CatalogAddOnBadgeWidgetView.tsx
new file mode 100644
index 0000000..657729b
--- /dev/null
+++ b/src/components/catalog-modern/views/page/widgets/CatalogAddOnBadgeWidgetView.tsx
@@ -0,0 +1,18 @@
+import { FC } from 'react';
+import { BaseProps, LayoutBadgeImageView } from '../../../../../common';
+import { useCatalogData } from '../../../../../hooks';
+
+interface CatalogAddOnBadgeWidgetViewProps extends BaseProps
+{
+
+}
+
+export const CatalogAddOnBadgeWidgetView: FC = props =>
+{
+ const { ...rest } = props;
+ const { currentOffer = null } = useCatalogData();
+
+ if(!currentOffer || !currentOffer.badgeCode || !currentOffer.badgeCode.length) return null;
+
+ return ;
+};
diff --git a/src/components/catalog-modern/views/page/widgets/CatalogBadgeSelectorWidgetView.tsx b/src/components/catalog-modern/views/page/widgets/CatalogBadgeSelectorWidgetView.tsx
new file mode 100644
index 0000000..8ef5c4d
--- /dev/null
+++ b/src/components/catalog-modern/views/page/widgets/CatalogBadgeSelectorWidgetView.tsx
@@ -0,0 +1,77 @@
+import { StringDataType } from '@nitrots/nitro-renderer';
+import { FC, useEffect, useMemo, useState } from 'react';
+import { AutoGrid, AutoGridProps, LayoutBadgeImageView, LayoutGridItem } from '../../../../../common';
+import { useCatalogData, useCatalogUiState, useInventoryBadges } from '../../../../../hooks';
+
+const EXCLUDED_BADGE_CODES: string[] = [];
+
+interface CatalogBadgeSelectorWidgetViewProps extends AutoGridProps
+{
+
+}
+
+export const CatalogBadgeSelectorWidgetView: FC = props =>
+{
+ const { columnCount = 5, ...rest } = props;
+ const [ isVisible, setIsVisible ] = useState(false);
+ const [ currentBadgeCode, setCurrentBadgeCode ] = useState(null);
+ const { currentOffer = null } = useCatalogData();
+ const { setPurchaseOptions = null } = useCatalogUiState();
+ const { badgeCodes = [], activate = null, deactivate = null } = useInventoryBadges();
+
+ const previewStuffData = useMemo(() =>
+ {
+ if(!currentBadgeCode) return null;
+
+ const stuffData = new StringDataType();
+
+ stuffData.setValue([ '0', currentBadgeCode, '', '' ]);
+
+ return stuffData;
+ }, [ currentBadgeCode ]);
+
+ useEffect(() =>
+ {
+ if(!currentOffer) return;
+
+ setPurchaseOptions(prevValue =>
+ {
+ const newValue = { ...prevValue };
+
+ newValue.extraParamRequired = true;
+ newValue.extraData = ((previewStuffData && previewStuffData.getValue(1)) || null);
+ newValue.previewStuffData = previewStuffData;
+
+ return newValue;
+ });
+ }, [ currentOffer, previewStuffData, setPurchaseOptions ]);
+
+ useEffect(() =>
+ {
+ if(!isVisible) return;
+
+ const id = activate();
+
+ return () => deactivate(id);
+ }, [ isVisible, activate, deactivate ]);
+
+ useEffect(() =>
+ {
+ setIsVisible(true);
+
+ return () => setIsVisible(false);
+ }, []);
+
+ return (
+
+ { badgeCodes && (badgeCodes.length > 0) && badgeCodes.map((badgeCode, index) =>
+ {
+ return (
+ setCurrentBadgeCode(badgeCode) }>
+
+
+ );
+ }) }
+
+ );
+};
diff --git a/src/components/catalog-modern/views/page/widgets/CatalogBundleGridWidgetView.tsx b/src/components/catalog-modern/views/page/widgets/CatalogBundleGridWidgetView.tsx
new file mode 100644
index 0000000..249bc87
--- /dev/null
+++ b/src/components/catalog-modern/views/page/widgets/CatalogBundleGridWidgetView.tsx
@@ -0,0 +1,30 @@
+import { FC, useEffect, useRef } from 'react';
+import { AutoGrid, AutoGridProps, LayoutGridItem } from '../../../../../common';
+import { useCatalogData } from '../../../../../hooks';
+import { getFurniIconUrl } from '../common/getFurniIconUrl';
+
+interface CatalogBundleGridWidgetViewProps extends AutoGridProps
+{
+
+}
+
+export const CatalogBundleGridWidgetView: FC = props =>
+{
+ const { columnCount = 5, children = null, ...rest } = props;
+ const { currentOffer = null } = useCatalogData();
+ const elementRef = useRef(null);
+
+ useEffect(() =>
+ {
+ if(elementRef && elementRef.current) elementRef.current.scrollTop = 0;
+ }, [ currentOffer ]);
+
+ if(!currentOffer) return null;
+
+ return (
+
+ { currentOffer.products && (currentOffer.products.length > 0) && currentOffer.products.map((product, index) => ) }
+ { children }
+
+ );
+};
diff --git a/src/components/catalog-modern/views/page/widgets/CatalogFirstProductSelectorWidgetView.tsx b/src/components/catalog-modern/views/page/widgets/CatalogFirstProductSelectorWidgetView.tsx
new file mode 100644
index 0000000..c5e9542
--- /dev/null
+++ b/src/components/catalog-modern/views/page/widgets/CatalogFirstProductSelectorWidgetView.tsx
@@ -0,0 +1,17 @@
+import { FC, useEffect } from 'react';
+import { useCatalogData, useCatalogUiState } from '../../../../../hooks';
+
+export const CatalogFirstProductSelectorWidgetView: FC<{}> = props =>
+{
+ const { currentPage = null } = useCatalogData();
+ const { setCurrentOffer = null } = useCatalogUiState();
+
+ useEffect(() =>
+ {
+ if(!currentPage || !currentPage.offers.length) return;
+
+ setCurrentOffer(currentPage.offers[0]);
+ }, [ currentPage, setCurrentOffer ]);
+
+ return null;
+};
diff --git a/src/components/catalog-modern/views/page/widgets/CatalogGuildBadgeWidgetView.tsx b/src/components/catalog-modern/views/page/widgets/CatalogGuildBadgeWidgetView.tsx
new file mode 100644
index 0000000..c69c895
--- /dev/null
+++ b/src/components/catalog-modern/views/page/widgets/CatalogGuildBadgeWidgetView.tsx
@@ -0,0 +1,32 @@
+import { StringDataType } from '@nitrots/nitro-renderer';
+import { FC, useMemo } from 'react';
+import { BaseProps, LayoutBadgeImageView } from '../../../../../common';
+import { useCatalogData, useCatalogUiState } from '../../../../../hooks';
+
+interface CatalogGuildBadgeWidgetViewProps extends BaseProps
+{
+
+}
+
+export const CatalogGuildBadgeWidgetView: FC = props =>
+{
+ const { ...rest } = props;
+ const { currentOffer = null } = useCatalogData();
+ const { purchaseOptions = null } = useCatalogUiState();
+ const { previewStuffData = null } = purchaseOptions;
+
+ const badgeCode = useMemo(() =>
+ {
+ if(!currentOffer || !previewStuffData) return null;
+
+ const badgeCode = (previewStuffData as StringDataType).getValue(2);
+
+ if(!badgeCode || !badgeCode.length) return null;
+
+ return badgeCode;
+ }, [ currentOffer, previewStuffData ]);
+
+ if(!badgeCode) return null;
+
+ return ;
+};
diff --git a/src/components/catalog-modern/views/page/widgets/CatalogGuildSelectorWidgetView.tsx b/src/components/catalog-modern/views/page/widgets/CatalogGuildSelectorWidgetView.tsx
new file mode 100644
index 0000000..3d9e4a4
--- /dev/null
+++ b/src/components/catalog-modern/views/page/widgets/CatalogGuildSelectorWidgetView.tsx
@@ -0,0 +1,71 @@
+import { StringDataType } from '@nitrots/nitro-renderer';
+import { FC, useEffect, useMemo, useState } from 'react';
+import { LocalizeText } from '../../../../../api';
+import { Button, Flex } from '../../../../../common';
+import { useCatalogData, useCatalogUiState, useUserGroups } from '../../../../../hooks';
+
+export const CatalogGuildSelectorWidgetView: FC<{}> = props =>
+{
+ const [ selectedGroupIndex, setSelectedGroupIndex ] = useState(0);
+ const { currentOffer = null } = useCatalogData();
+ const { setPurchaseOptions = null } = useCatalogUiState();
+ const { data: groups = null } = useUserGroups();
+
+ const previewStuffData = useMemo(() =>
+ {
+ if(!groups || !groups.length) return null;
+
+ const group = groups[selectedGroupIndex];
+
+ if(!group) return null;
+
+ const stuffData = new StringDataType();
+
+ stuffData.setValue([ '0', group.groupId.toString(), group.badgeCode, group.colorA, group.colorB ]);
+
+ return stuffData;
+ }, [ selectedGroupIndex, groups ]);
+
+ useEffect(() =>
+ {
+ if(!currentOffer) return;
+
+ setPurchaseOptions(prevValue =>
+ {
+ const newValue = { ...prevValue };
+
+ newValue.extraParamRequired = true;
+ newValue.extraData = ((previewStuffData && previewStuffData.getValue(1)) || null);
+ newValue.previewStuffData = previewStuffData;
+
+ return newValue;
+ });
+ }, [ currentOffer, previewStuffData, setPurchaseOptions ]);
+
+ if(!groups || !groups.length)
+ {
+ return (
+
+ { LocalizeText('catalog.guild_selector.members_only') }
+
+
+ );
+ }
+
+ const selectedGroup = groups[selectedGroupIndex];
+
+ return (
+
+ { !!selectedGroup &&
+
+
+
+ }
+
+
+ );
+};
diff --git a/src/components/catalog-modern/views/page/widgets/CatalogItemGridWidgetView.tsx b/src/components/catalog-modern/views/page/widgets/CatalogItemGridWidgetView.tsx
new file mode 100644
index 0000000..e34dbb8
--- /dev/null
+++ b/src/components/catalog-modern/views/page/widgets/CatalogItemGridWidgetView.tsx
@@ -0,0 +1,106 @@
+import { FC, useCallback, useEffect, useRef, useState } from 'react';
+import { IPurchasableOffer } from '../../../../../api';
+import { AutoGrid, AutoGridProps } from '../../../../../common';
+import { useCatalogActions, useCatalogData } from '../../../../../hooks';
+import { useCatalogAdmin } from '../../../CatalogAdminContext';
+import { CatalogGridOfferView } from '../common/CatalogGridOfferView';
+
+interface CatalogItemGridWidgetViewProps extends AutoGridProps
+{
+
+}
+
+export const CatalogItemGridWidgetView: FC = props =>
+{
+ const { columnCount = 5, children = null, ...rest } = props;
+ const { currentOffer = null, currentPage = null } = useCatalogData();
+ const { selectCatalogOffer = null } = useCatalogActions();
+ const catalogAdmin = useCatalogAdmin();
+ const adminMode = catalogAdmin?.adminMode ?? false;
+ const elementRef = useRef(null);
+ const [ dragIndex, setDragIndex ] = useState(null);
+ const [ dropIndex, setDropIndex ] = useState(null);
+
+ useEffect(() =>
+ {
+ if(elementRef && elementRef.current) elementRef.current.scrollTop = 0;
+ }, [ currentPage ]);
+
+ // Drag-and-drop handlers — hooks MUST run unconditionally so the
+ // hook order stays stable when currentPage flips from null to a
+ // real value (the `if(!currentPage) return null` below would
+ // otherwise hide these from the first render and React would flag
+ // "Rendered more hooks than during the previous render"). Bodies
+ // are safe to evaluate pre-load: currentPage? optional chaining
+ // already guards the only access inside handleDrop.
+ const handleDragStart = useCallback((index: number) =>
+ {
+ setDragIndex(index);
+ }, []);
+
+ const handleDragOver = useCallback((e: React.DragEvent, index: number) =>
+ {
+ e.preventDefault();
+ setDropIndex(index);
+ }, []);
+
+ const handleDrop = useCallback((index: number) =>
+ {
+ if(dragIndex !== null && dragIndex !== index && currentPage?.offers)
+ {
+ const offers = [ ...currentPage.offers ];
+ const [ moved ] = offers.splice(dragIndex, 1);
+
+ offers.splice(index, 0, moved);
+
+ const orders = offers.map((o, i) => ({ id: o.offerId, orderNumber: i }));
+
+ catalogAdmin?.reorderOffers(orders);
+ }
+
+ setDragIndex(null);
+ setDropIndex(null);
+ }, [ dragIndex, currentPage, catalogAdmin ]);
+
+ const handleDragEnd = useCallback(() =>
+ {
+ setDragIndex(null);
+ setDropIndex(null);
+ }, []);
+
+ if(!currentPage) return null;
+
+ const selectOffer = (offer: IPurchasableOffer) =>
+ {
+ selectCatalogOffer(offer);
+ };
+
+ return (
+
+ { currentPage.offers && (currentPage.offers.length > 0) && currentPage.offers.map((offer, index) =>
+ {
+ const isDragging = dragIndex === index;
+ const isDropTarget = dropIndex === index && dragIndex !== index;
+
+ return (
+ handleDragOver(e, index) : undefined }
+ onDragStart={ adminMode ? () => handleDragStart(index) : undefined }
+ onDrop={ adminMode ? () => handleDrop(index) : undefined }
+ >
+
+
+ );
+ }) }
+ { children }
+
+ );
+};
diff --git a/src/components/catalog-modern/views/page/widgets/CatalogLimitedItemWidgetView.tsx b/src/components/catalog-modern/views/page/widgets/CatalogLimitedItemWidgetView.tsx
new file mode 100644
index 0000000..faf02a2
--- /dev/null
+++ b/src/components/catalog-modern/views/page/widgets/CatalogLimitedItemWidgetView.tsx
@@ -0,0 +1,17 @@
+import { FC } from 'react';
+import { Offer } from '../../../../../api';
+import { LayoutLimitedEditionCompletePlateView } from '../../../../../common';
+import { useCatalogData } from '../../../../../hooks';
+
+export const CatalogLimitedItemWidgetView: FC = props =>
+{
+ const { currentOffer = null } = useCatalogData();
+
+ if(!currentOffer || (currentOffer.pricingModel !== Offer.PRICING_MODEL_SINGLE) || !currentOffer.product.isUniqueLimitedItem) return null;
+
+ return (
+
+
+
+ );
+};
diff --git a/src/components/catalog-modern/views/page/widgets/CatalogPriceDisplayWidgetView.tsx b/src/components/catalog-modern/views/page/widgets/CatalogPriceDisplayWidgetView.tsx
new file mode 100644
index 0000000..a83a981
--- /dev/null
+++ b/src/components/catalog-modern/views/page/widgets/CatalogPriceDisplayWidgetView.tsx
@@ -0,0 +1,37 @@
+import { FC } from 'react';
+import { FaPlus } from 'react-icons/fa';
+import { IPurchasableOffer } from '../../../../../api';
+import { LayoutCurrencyIcon, Text } from '../../../../../common';
+import { useCatalogUiState } from '../../../../../hooks';
+
+interface CatalogPriceDisplayWidgetViewProps
+{
+ offer: IPurchasableOffer;
+ separator?: boolean;
+}
+
+export const CatalogPriceDisplayWidgetView: FC = props =>
+{
+ const { offer = null, separator = false } = props;
+ const { purchaseOptions = null } = useCatalogUiState();
+ const { quantity = 1 } = purchaseOptions;
+
+ if(!offer) return null;
+
+ return (
+
+ { (offer.priceInCredits > 0) &&
+
+ { (offer.priceInCredits * quantity) }
+
+
}
+ { separator && (offer.priceInCredits > 0) && (offer.priceInActivityPoints > 0) &&
+
}
+ { (offer.priceInActivityPoints > 0) &&
+
+ { (offer.priceInActivityPoints * quantity) }
+
+
}
+
+ );
+};
diff --git a/src/components/catalog-modern/views/page/widgets/CatalogPurchaseWidgetView.tsx b/src/components/catalog-modern/views/page/widgets/CatalogPurchaseWidgetView.tsx
new file mode 100644
index 0000000..e04cac0
--- /dev/null
+++ b/src/components/catalog-modern/views/page/widgets/CatalogPurchaseWidgetView.tsx
@@ -0,0 +1,256 @@
+import { CreateLinkEvent, PurchaseFromCatalogComposer } from '@nitrots/nitro-renderer';
+import { FC, useCallback, useEffect, useMemo, useState } from 'react';
+import { BuilderFurniPlaceableStatus, CatalogPurchaseState, CatalogType, DispatchUiEvent, GetClubMemberLevel, LocalStorageKeys, LocalizeText, NotificationBubbleType, Offer, ProductTypeEnum, SendMessageComposer } from '../../../../../api';
+import { Button, LayoutLoadingSpinnerView, Text } from '../../../../../common';
+import { CatalogEvent, CatalogInitGiftEvent, CatalogPurchaseFailureEvent, CatalogPurchaseNotAllowedEvent, CatalogPurchaseSoldOutEvent, CatalogPurchasedEvent } from '../../../../../events';
+import { useCatalogActions, useCatalogData, useCatalogUiState, useLocalStorage, useNotification, usePurse, useUiEvent } from '../../../../../hooks';
+
+interface CatalogPurchaseWidgetViewProps
+{
+ noGiftOption?: boolean;
+ purchaseCallback?: () => void;
+}
+
+let isPurchasingCatalogItem = false;
+
+export const CatalogPurchaseWidgetView: FC = 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 } = useCatalogData();
+ const { currentType = CatalogType.NORMAL, purchaseOptions = null, setPurchaseOptions = null, setCatalogPlaceMultipleObjects = null } = useCatalogUiState();
+ const { requestOfferToMover = null, getBuilderFurniPlaceableStatus = null, getNodesByOfferId = null } = useCatalogActions();
+ const { getCurrencyAmount = null } = usePurse();
+ const { showSingleBubble = null } = useNotification();
+
+ const onCatalogEvent = useCallback((event: CatalogEvent) =>
+ {
+ switch(event.type)
+ {
+ case CatalogPurchasedEvent.PURCHASE_SUCCESS:
+ isPurchasingCatalogItem = false;
+ setPurchaseState(CatalogPurchaseState.NONE);
+ return;
+ case CatalogPurchaseFailureEvent.PURCHASE_FAILED:
+ isPurchasingCatalogItem = false;
+ setPurchaseState(CatalogPurchaseState.FAILED);
+ return;
+ case CatalogPurchaseNotAllowedEvent.NOT_ALLOWED:
+ isPurchasingCatalogItem = false;
+ setPurchaseState(CatalogPurchaseState.FAILED);
+ return;
+ case CatalogPurchaseSoldOutEvent.SOLD_OUT:
+ isPurchasingCatalogItem = false;
+ setPurchaseState(CatalogPurchaseState.SOLD_OUT);
+ return;
+ }
+ }, []);
+
+ useUiEvent(CatalogPurchasedEvent.PURCHASE_SUCCESS, onCatalogEvent);
+ useUiEvent(CatalogPurchaseFailureEvent.PURCHASE_FAILED, onCatalogEvent);
+ useUiEvent(CatalogPurchaseNotAllowedEvent.NOT_ALLOWED, onCatalogEvent);
+ useUiEvent(CatalogPurchaseSoldOutEvent.SOLD_OUT, onCatalogEvent);
+
+ const isLimitedSoldOut = useMemo(() =>
+ {
+ if(!currentOffer) return false;
+
+ if(purchaseOptions.extraParamRequired && (!purchaseOptions.extraData || !purchaseOptions.extraData.length)) return false;
+
+ if(currentOffer.pricingModel === Offer.PRICING_MODEL_SINGLE)
+ {
+ const product = currentOffer.product;
+
+ if(product && product.isUniqueLimitedItem) return !product.uniqueLimitedItemsLeft;
+ }
+
+ return false;
+ }, [ currentOffer, purchaseOptions ]);
+
+ const purchase = (isGift: boolean = false) =>
+ {
+ if(!currentOffer || isPurchasingCatalogItem) return;
+
+ if(GetClubMemberLevel() < currentOffer.clubLevel)
+ {
+ CreateLinkEvent('habboUI/open/hccenter');
+
+ return;
+ }
+
+ if(isGift)
+ {
+ DispatchUiEvent(new CatalogInitGiftEvent(currentOffer.page.pageId, currentOffer.offerId, purchaseOptions.extraData));
+
+ return;
+ }
+
+ isPurchasingCatalogItem = true;
+ setPurchaseState(CatalogPurchaseState.PURCHASE);
+
+ setTimeout(() =>
+ {
+ isPurchasingCatalogItem = false;
+ }, 10000);
+
+ if(purchaseCallback)
+ {
+ purchaseCallback();
+
+ return;
+ }
+
+ let pageId = currentOffer.page.pageId;
+
+ if(pageId === -1 && getNodesByOfferId)
+ {
+ const nodes = getNodesByOfferId(currentOffer.offerId);
+ if(nodes && nodes.length) pageId = nodes[0].pageId;
+ }
+
+ SendMessageComposer(new PurchaseFromCatalogComposer(pageId, currentOffer.offerId, purchaseOptions.extraData, purchaseOptions.quantity));
+ };
+
+ useEffect(() =>
+ {
+ if(!currentOffer) return;
+
+ setPurchaseState(CatalogPurchaseState.NONE);
+ }, [ currentOffer, setPurchaseOptions ]);
+
+ useEffect(() =>
+ {
+ let timeout: ReturnType = null;
+
+ if((purchaseState === CatalogPurchaseState.CONFIRM) || (purchaseState === CatalogPurchaseState.FAILED))
+ {
+ timeout = setTimeout(() => setPurchaseState(CatalogPurchaseState.NONE), 3000);
+ }
+
+ return () =>
+ {
+ if(timeout) clearTimeout(timeout);
+ };
+ }, [ purchaseState ]);
+
+ // Builders-club state — derived + hooks MUST run unconditionally on
+ // every render so the hook order stays stable even when currentOffer
+ // is null (the `if(!currentOffer) return null` below would otherwise
+ // hide the useMemo/useEffect block from the first render and React
+ // would flag "Rendered more hooks than during the previous render").
+ const isBuildersClubOffer = (currentType === CatalogType.BUILDER);
+ const isBuildersClubPlaceable = isBuildersClubOffer
+ && !!currentOffer
+ && !!currentOffer.product
+ && ((currentOffer.product.productType === ProductTypeEnum.FLOOR) || (currentOffer.product.productType === ProductTypeEnum.WALL));
+ const builderPlaceableStatus = useMemo(() =>
+ {
+ if(!isBuildersClubPlaceable || !getBuilderFurniPlaceableStatus || !currentOffer) 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 ]);
+
+ if(!currentOffer) return null;
+
+ 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 (
+
+
+
+
+
+ { isBlockedByVisitors &&
+
+ { LocalizeText('builder.placement_widget.error.visitors') }
+ }
+ { (builderPlaceableStatus === BuilderFurniPlaceableStatus.NOT_GROUP_ADMIN) &&
+
+ { LocalizeText('builder.placement_widget.error.not_group_admin') }
+ }
+
+ );
+ }
+
+ const priceCredits = (currentOffer.priceInCredits * purchaseOptions.quantity);
+ const pricePoints = (currentOffer.priceInActivityPoints * purchaseOptions.quantity);
+
+ if(GetClubMemberLevel() < currentOffer.clubLevel) return ;
+
+ if(isLimitedSoldOut) return ;
+
+ if(priceCredits > getCurrencyAmount(-1)) return ;
+
+ if(pricePoints > getCurrencyAmount(currentOffer.activityPointType)) return ;
+
+ switch(purchaseState)
+ {
+ case CatalogPurchaseState.CONFIRM:
+ return ;
+ case CatalogPurchaseState.PURCHASE:
+ return ;
+ case CatalogPurchaseState.FAILED:
+ return ;
+ case CatalogPurchaseState.SOLD_OUT:
+ return ;
+ case CatalogPurchaseState.NONE:
+ default:
+ return ;
+ }
+ };
+
+ return (
+ <>
+
+ { (!isBuildersClubOffer && !noGiftOption && !currentOffer.isRentOffer) &&
+ }
+ >
+ );
+};
diff --git a/src/components/catalog-modern/views/page/widgets/CatalogSimplePriceWidgetView.tsx b/src/components/catalog-modern/views/page/widgets/CatalogSimplePriceWidgetView.tsx
new file mode 100644
index 0000000..c34fe4a
--- /dev/null
+++ b/src/components/catalog-modern/views/page/widgets/CatalogSimplePriceWidgetView.tsx
@@ -0,0 +1,14 @@
+import { FC } from 'react';
+import { useCatalogData } from '../../../../../hooks';
+import { CatalogPriceDisplayWidgetView } from './CatalogPriceDisplayWidgetView';
+
+export const CatalogSimplePriceWidgetView: FC<{}> = props =>
+{
+ const { currentOffer = null } = useCatalogData();
+
+ return (
+
+
+
+ );
+};
diff --git a/src/components/catalog-modern/views/page/widgets/CatalogSingleViewWidgetView.tsx b/src/components/catalog-modern/views/page/widgets/CatalogSingleViewWidgetView.tsx
new file mode 100644
index 0000000..c517acd
--- /dev/null
+++ b/src/components/catalog-modern/views/page/widgets/CatalogSingleViewWidgetView.tsx
@@ -0,0 +1,7 @@
+import { FC } from 'react';
+import { CatalogFirstProductSelectorWidgetView } from './CatalogFirstProductSelectorWidgetView';
+
+export const CatalogSingleViewWidgetView: FC<{}> = props =>
+{
+ return ;
+};
diff --git a/src/components/catalog-modern/views/page/widgets/CatalogSpacesWidgetView.tsx b/src/components/catalog-modern/views/page/widgets/CatalogSpacesWidgetView.tsx
new file mode 100644
index 0000000..b100cbe
--- /dev/null
+++ b/src/components/catalog-modern/views/page/widgets/CatalogSpacesWidgetView.tsx
@@ -0,0 +1,116 @@
+import { FC, useEffect, useRef, useState } from 'react';
+import { IPurchasableOffer, LocalizeText, Offer, ProductTypeEnum } from '../../../../../api';
+import { AutoGrid, AutoGridProps, Button } from '../../../../../common';
+import { useCatalogData, useCatalogUiState } from '../../../../../hooks';
+import { CatalogGridOfferView } from '../common/CatalogGridOfferView';
+
+interface CatalogSpacesWidgetViewProps extends AutoGridProps
+{
+
+}
+
+const SPACES_GROUP_NAMES = [ 'floors', 'walls', 'views' ];
+
+export const CatalogSpacesWidgetView: FC = props =>
+{
+ const { columnCount = 5, children = null, ...rest } = props;
+ const [ groupedOffers, setGroupedOffers ] = useState(null);
+ const [ selectedGroupIndex, setSelectedGroupIndex ] = useState(-1);
+ const [ selectedOfferForGroup, setSelectedOfferForGroup ] = useState(null);
+ const { currentPage = null, currentOffer = null } = useCatalogData();
+ const { setCurrentOffer = null, setPurchaseOptions = null } = useCatalogUiState();
+ const elementRef = useRef(null);
+
+ const setSelectedOffer = (offer: IPurchasableOffer) =>
+ {
+ if(!offer) return;
+
+ setSelectedOfferForGroup(prevValue =>
+ {
+ const newValue = [ ...prevValue ];
+
+ newValue[selectedGroupIndex] = offer;
+
+ return newValue;
+ });
+ };
+
+ useEffect(() =>
+ {
+ if(!currentPage) return;
+
+ const groupedOffers: IPurchasableOffer[][] = [ [], [], [] ];
+
+ for(const offer of currentPage.offers)
+ {
+ if((offer.pricingModel !== Offer.PRICING_MODEL_SINGLE) && (offer.pricingModel !== Offer.PRICING_MODEL_MULTI)) continue;
+
+ const product = offer.product;
+
+ if(!product || ((product.productType !== ProductTypeEnum.WALL) && (product.productType !== ProductTypeEnum.FLOOR)) || !product.furnitureData) continue;
+
+ const className = product.furnitureData.className;
+
+ switch(className)
+ {
+ case 'floor':
+ groupedOffers[0].push(offer);
+ break;
+ case 'wallpaper':
+ groupedOffers[1].push(offer);
+ break;
+ case 'landscape':
+ groupedOffers[2].push(offer);
+ break;
+ }
+ }
+
+ setGroupedOffers(groupedOffers);
+ setSelectedGroupIndex(0);
+ setSelectedOfferForGroup([ groupedOffers[0][0], groupedOffers[1][0], groupedOffers[2][0] ]);
+ }, [ currentPage ]);
+
+ useEffect(() =>
+ {
+ if((selectedGroupIndex === -1) || !selectedOfferForGroup) return;
+
+ setCurrentOffer(selectedOfferForGroup[selectedGroupIndex]);
+
+ }, [ selectedGroupIndex, selectedOfferForGroup, setCurrentOffer ]);
+
+ useEffect(() =>
+ {
+ if((selectedGroupIndex === -1) || !selectedOfferForGroup || !currentOffer) return;
+
+ setPurchaseOptions(prevValue =>
+ {
+ const newValue = { ...prevValue };
+
+ newValue.extraData = selectedOfferForGroup[selectedGroupIndex].product.extraParam;
+ newValue.extraParamRequired = true;
+
+ return newValue;
+ });
+ }, [ currentOffer, selectedGroupIndex, selectedOfferForGroup, setPurchaseOptions ]);
+
+ useEffect(() =>
+ {
+ if(elementRef && elementRef.current) elementRef.current.scrollTop = 0;
+ }, [ selectedGroupIndex ]);
+
+ if(!groupedOffers || (selectedGroupIndex === -1)) return null;
+
+ const offers = groupedOffers[selectedGroupIndex];
+
+ return (
+ <>
+
+ { SPACES_GROUP_NAMES.map((name, index) => ) }
+
+
+ { offers && (offers.length > 0) && offers.map((offer, index) => setSelectedOffer(offer) } />) }
+ { children }
+
+ >
+ );
+};
diff --git a/src/components/catalog-modern/views/page/widgets/CatalogSpinnerWidgetView.tsx b/src/components/catalog-modern/views/page/widgets/CatalogSpinnerWidgetView.tsx
new file mode 100644
index 0000000..573465e
--- /dev/null
+++ b/src/components/catalog-modern/views/page/widgets/CatalogSpinnerWidgetView.tsx
@@ -0,0 +1,61 @@
+import { FC } from 'react';
+import { FaMinus, FaPlus } from 'react-icons/fa';
+import { LocalizeText } from '../../../../../api';
+import { useCatalogData, useCatalogUiState } from '../../../../../hooks';
+
+const MIN_VALUE: number = 1;
+const MAX_VALUE: number = 99;
+
+export const CatalogSpinnerWidgetView: FC<{}> = props =>
+{
+ const { currentOffer = null } = useCatalogData();
+ const { purchaseOptions = null, setPurchaseOptions = null } = useCatalogUiState();
+ const { quantity = 1 } = purchaseOptions;
+
+ const updateQuantity = (value: number) =>
+ {
+ if(isNaN(value)) value = 1;
+
+ value = Math.max(value, MIN_VALUE);
+ value = Math.min(value, MAX_VALUE);
+
+ if(value === quantity) return;
+
+ setPurchaseOptions(prevValue =>
+ {
+ const newValue = { ...prevValue };
+
+ newValue.quantity = value;
+
+ return newValue;
+ });
+ };
+
+ if(!currentOffer || !currentOffer.bundlePurchaseAllowed) return null;
+
+ return (
+
+
{ LocalizeText('catalog.bundlewidget.spinner.select.amount') }
+
+
+ updateQuantity(event.target.valueAsNumber) }
+ />
+
+
+
+ );
+};
diff --git a/src/components/catalog-modern/views/page/widgets/CatalogTotalPriceWidget.tsx b/src/components/catalog-modern/views/page/widgets/CatalogTotalPriceWidget.tsx
new file mode 100644
index 0000000..2ad6129
--- /dev/null
+++ b/src/components/catalog-modern/views/page/widgets/CatalogTotalPriceWidget.tsx
@@ -0,0 +1,20 @@
+import { FC } from 'react';
+import { Column, ColumnProps } from '../../../../../common';
+import { useCatalogData } from '../../../../../hooks';
+import { CatalogPriceDisplayWidgetView } from './CatalogPriceDisplayWidgetView';
+
+interface CatalogSimplePriceWidgetViewProps extends ColumnProps
+{
+
+}
+export const CatalogTotalPriceWidget: FC = props =>
+{
+ const { gap = 1, ...rest } = props;
+ const { currentOffer = null } = useCatalogData();
+
+ return (
+
+
+
+ );
+};
diff --git a/src/components/catalog-modern/views/page/widgets/CatalogViewProductWidgetView.tsx b/src/components/catalog-modern/views/page/widgets/CatalogViewProductWidgetView.tsx
new file mode 100644
index 0000000..46c0184
--- /dev/null
+++ b/src/components/catalog-modern/views/page/widgets/CatalogViewProductWidgetView.tsx
@@ -0,0 +1,129 @@
+import { GetAvatarRenderManager, GetSessionDataManager, Vector3d } from '@nitrots/nitro-renderer';
+import { FC, useEffect } from 'react';
+import { BuildPurchasableClothingFigure, FurniCategory, Offer, ProductTypeEnum } from '../../../../../api';
+import { AutoGrid, Column, LayoutGridItem, LayoutRoomPreviewerView } from '../../../../../common';
+import { useCatalogData, useCatalogUiState } from '../../../../../hooks';
+
+export const CatalogViewProductWidgetView: FC<{}> = props =>
+{
+ const { currentOffer = null, roomPreviewer = null } = useCatalogData();
+ const { purchaseOptions = null } = useCatalogUiState();
+ const { previewStuffData = null } = purchaseOptions;
+
+ useEffect(() =>
+ {
+ if(!currentOffer || (currentOffer.pricingModel === Offer.PRICING_MODEL_BUNDLE) || !roomPreviewer) return;
+
+ const product = currentOffer.product;
+
+ if(!product) return;
+
+ roomPreviewer.reset(false);
+ roomPreviewer.updateObjectRoom('111', '217', '1.1');
+ roomPreviewer.updateRoomWallsAndFloorVisibility(true, true);
+
+ const populate = () =>
+ {
+ switch(product.productType)
+ {
+ case ProductTypeEnum.FLOOR: {
+ if(!product.furnitureData) return;
+
+ const furniData = GetSessionDataManager().getFloorItemData(product.furnitureData.id);
+ const isPurchasableClothing = (product.furnitureData.specialType === FurniCategory.FIGURE_PURCHASABLE_SET);
+ const hasResolvableFigureSets = (() =>
+ {
+ if(!furniData || !furniData.customParams || !furniData.customParams.length) return false;
+
+ const parts = furniData.customParams.split(',').map(value => parseInt(value));
+
+ for(const part of parts)
+ {
+ if(isNaN(part)) continue;
+
+ if(GetAvatarRenderManager().structureData?.getFigurePartSet(part)) return true;
+ }
+
+ return false;
+ })();
+
+ if(isPurchasableClothing || hasResolvableFigureSets)
+ {
+ const customParts = furniData.customParams.split(',').map(value => parseInt(value));
+ const figureSets: number[] = [];
+
+ for(const part of customParts)
+ {
+ if(isNaN(part)) continue;
+
+ if(GetAvatarRenderManager().isValidFigureSetForGender(part, GetSessionDataManager().gender)) figureSets.push(part);
+ }
+
+ const figureString = BuildPurchasableClothingFigure(GetSessionDataManager().figure, figureSets);
+
+ roomPreviewer.addAvatarIntoRoom(figureString, product.productClassId);
+ }
+ else
+ {
+ roomPreviewer.addFurnitureIntoRoom(product.productClassId, new Vector3d(90), previewStuffData, product.extraParam);
+ }
+ return;
+ }
+ case ProductTypeEnum.WALL: {
+ if(!product.furnitureData) return;
+
+ roomPreviewer.updateRoomWallsAndFloorVisibility(true, true);
+
+ switch(product.furnitureData.specialType)
+ {
+ case FurniCategory.FLOOR:
+ roomPreviewer.updateObjectRoom(product.extraParam);
+ return;
+ case FurniCategory.WALL_PAPER:
+ roomPreviewer.updateObjectRoom(null, product.extraParam);
+ return;
+ case FurniCategory.LANDSCAPE: {
+ roomPreviewer.updateObjectRoom(null, null, product.extraParam);
+
+ const furniData = GetSessionDataManager().getWallItemDataByName('window_double_default');
+
+ if(furniData) roomPreviewer.addWallItemIntoRoom(furniData.id, new Vector3d(90), furniData.customParams);
+ return;
+ }
+ default:
+ roomPreviewer.updateObjectRoom('101', '101', '1.1');
+ roomPreviewer.addWallItemIntoRoom(product.productClassId, new Vector3d(90), product.extraParam);
+ return;
+ }
+ }
+ case ProductTypeEnum.ROBOT:
+ roomPreviewer.addAvatarIntoRoom(product.extraParam, 0);
+ return;
+ case ProductTypeEnum.EFFECT:
+ roomPreviewer.addAvatarIntoRoom(GetSessionDataManager().figure, product.productClassId);
+ return;
+ }
+ };
+
+ populate();
+ roomPreviewer.setAutomaticStateChange(false);
+ }, [ currentOffer, previewStuffData, roomPreviewer ]);
+
+ if(!currentOffer) return null;
+
+ if(currentOffer.pricingModel === Offer.PRICING_MODEL_BUNDLE)
+ {
+ return (
+
+
+ { (currentOffer.products.length > 0) && currentOffer.products.map((product, index) =>
+ {
+ return ;
+ }) }
+
+
+ );
+ }
+
+ return ;
+};
diff --git a/src/components/catalog-modern/views/targeted-offer/OfferBubbleView.tsx b/src/components/catalog-modern/views/targeted-offer/OfferBubbleView.tsx
new file mode 100644
index 0000000..170aeec
--- /dev/null
+++ b/src/components/catalog-modern/views/targeted-offer/OfferBubbleView.tsx
@@ -0,0 +1,16 @@
+import { TargetedOfferData } from '@nitrots/nitro-renderer';
+import { Dispatch, SetStateAction } from 'react';
+import { GetConfigurationValue } from '../../../../api';
+import { LayoutNotificationBubbleView, Text } from '../../../../common';
+
+export const OfferBubbleView = (props: { offer: TargetedOfferData, setOpen: Dispatch> }) =>
+{
+ const { offer = null, setOpen = null } = props;
+
+ if(!offer) return;
+
+ return setOpen(true) } onClose={ null }>
+
+ { offer.title }
+ ;
+};
diff --git a/src/components/catalog-modern/views/targeted-offer/OfferView.tsx b/src/components/catalog-modern/views/targeted-offer/OfferView.tsx
new file mode 100644
index 0000000..0ec2637
--- /dev/null
+++ b/src/components/catalog-modern/views/targeted-offer/OfferView.tsx
@@ -0,0 +1,28 @@
+import { GetTargetedOfferComposer, TargetedOfferData, TargetedOfferEvent } from '@nitrots/nitro-renderer';
+import { useState } from 'react';
+import { useNitroQuery } from '../../../../api/nitro-query';
+import { OfferBubbleView } from './OfferBubbleView';
+import { OfferWindowView } from './OfferWindowView';
+
+export const OfferView = () =>
+{
+ const { data: offer } = useNitroQuery({
+ key: [ 'nitro', 'catalog', 'targeted-offer' ],
+ request: () => new GetTargetedOfferComposer(),
+ parser: TargetedOfferEvent,
+ select: evt => evt.getParser()?.data ?? null,
+ staleTime: Infinity
+ });
+
+ const [ opened, setOpened ] = useState(false);
+
+ if(!offer) return null;
+
+ return (
+ <>
+ { opened
+ ?
+ : }
+ >
+ );
+};
diff --git a/src/components/catalog-modern/views/targeted-offer/OfferWindowView.tsx b/src/components/catalog-modern/views/targeted-offer/OfferWindowView.tsx
new file mode 100644
index 0000000..72f0658
--- /dev/null
+++ b/src/components/catalog-modern/views/targeted-offer/OfferWindowView.tsx
@@ -0,0 +1,93 @@
+import { GetTargetedOfferComposer, PurchaseTargetedOfferComposer, TargetedOfferData } from '@nitrots/nitro-renderer';
+import { Dispatch, SetStateAction, useMemo, useState } from 'react';
+import { FriendlyTime, GetConfigurationValue, LocalizeText, SanitizeHtml, SendMessageComposer } from '../../../../api';
+import { Button, Column, Flex, LayoutCurrencyIcon, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
+import { usePurse } from '../../../../hooks';
+
+let isBuyingOffer = false;
+
+export const OfferWindowView = (props: { offer: TargetedOfferData, setOpen: Dispatch> }) =>
+{
+ const { offer = null, setOpen = null } = props;
+
+ const { getCurrencyAmount } = usePurse();
+
+ const [ amount, setAmount ] = useState(1);
+
+ const canPurchase = useMemo(() =>
+ {
+ let credits = false;
+ let points = false;
+ let limit = false;
+
+ if(offer.priceInCredits > 0) credits = getCurrencyAmount(-1) >= offer.priceInCredits;
+
+ if(offer.priceInActivityPoints > 0) points = getCurrencyAmount(offer.activityPointType) >= offer.priceInActivityPoints;
+ else points = true;
+
+ if(offer.purchaseLimit > 0) limit = true;
+
+ return (credits && points && limit);
+ }, [ offer, getCurrencyAmount ]);
+
+ const expirationTime = () =>
+ {
+ let expirationTime = Math.max(0, (offer.expirationTime - Date.now()) / 1000);
+
+ return FriendlyTime.format(expirationTime);
+ };
+
+ const buyOffer = () =>
+ {
+ if(isBuyingOffer) return;
+
+ isBuyingOffer = true;
+
+ SendMessageComposer(new PurchaseTargetedOfferComposer(offer.id, amount));
+ SendMessageComposer(new GetTargetedOfferComposer());
+
+ setTimeout(() => isBuyingOffer = false, 5000);
+ };
+
+ if(!offer) return;
+
+ return
+ setOpen(false) } />
+
+ { LocalizeText('targeted.offer.timeleft', [ 'timeleft' ], [ expirationTime() ]) }
+
+
+
+
+
+
+ { LocalizeText(offer.title) }
+
+
+
+
+ { offer.purchaseLimit > 1 &&
+
+ { LocalizeText('catalog.bundlewidget.quantity') }
+ setAmount(parseInt(evt.target.value)) } />
+
}
+
+
+
+
+
+
+ { LocalizeText('targeted.offer.price.label') }
+ { offer.priceInCredits > 0 &&
+
+ { offer.priceInCredits }
+
+
}
+ { offer.priceInActivityPoints > 0 &&
+
+ +{ offer.priceInActivityPoints }
+
}
+
+
+ ;
+};
diff --git a/src/components/catalog/CatalogView.tsx b/src/components/catalog/CatalogView.tsx
index 87344fe..3a4527c 100644
--- a/src/components/catalog/CatalogView.tsx
+++ b/src/components/catalog/CatalogView.tsx
@@ -1,16 +1,19 @@
import { FC } from 'react';
import { useCatalogClassicStyle, useCatalogData } from '../../hooks';
import { CatalogClassicView } from './CatalogClassicView';
-import { CatalogModernView } from './CatalogModernView';
+// Modern catalog FULLY FORKED into ../catalog-modern/* (own copy of every catalog
+// component) so the two catalogs share NOTHING: editing the modern one never
+// touches duckie's classic, which stays 1:1 upstream.
+import { CatalogModernView } from '../catalog-modern/CatalogModernView';
export const CatalogView: FC<{}> = () =>
{
const { catalogLocalizationVersion = 0 } = useCatalogData();
const [ catalogClassicStyle ] = useCatalogClassicStyle();
- // Default = upstream rebuilt catalog (CatalogClassicView, latest release theme).
- // The "stile classico" toggle (or global catalog.classic.style flag) switches
- // to the Hippiehotel.nl catalog (CatalogModernView, self-contained tailwind).
+ // Default (toggle OFF) = duckie's classic catalog 1:1 upstream (./CatalogClassicView,
+ // uses the original ./views/* tree). Toggle ON = the modern catalog, a fully
+ // self-contained fork under ../catalog-modern/* — nothing shared between the two.
// Both the normal catalog and the Builders Club follow this toggle.
if(catalogClassicStyle) return (
<>
diff --git a/src/components/catalog/views/page/widgets/CatalogPurchaseWidgetView.tsx b/src/components/catalog/views/page/widgets/CatalogPurchaseWidgetView.tsx
index 44feade..fb45da8 100644
--- a/src/components/catalog/views/page/widgets/CatalogPurchaseWidgetView.tsx
+++ b/src/components/catalog/views/page/widgets/CatalogPurchaseWidgetView.tsx
@@ -233,7 +233,7 @@ export const CatalogPurchaseWidgetView: FC = pro
switch(purchaseState)
{
case CatalogPurchaseState.CONFIRM:
- return ;
+ return ;
case CatalogPurchaseState.PURCHASE:
return ;
case CatalogPurchaseState.FAILED:
diff --git a/src/components/navigator/views/search/NavigatorSearchSavesResultItemView.tsx b/src/components/navigator/views/search/NavigatorSearchSavesResultItemView.tsx
index 030ea36..b8f5dfa 100644
--- a/src/components/navigator/views/search/NavigatorSearchSavesResultItemView.tsx
+++ b/src/components/navigator/views/search/NavigatorSearchSavesResultItemView.tsx
@@ -1,8 +1,9 @@
-import { NavigatorDeleteSavedSearchComposer, NavigatorSavedSearch, NavigatorSearchComposer } from '@nitrots/nitro-renderer';
+import { NavigatorDeleteSavedSearchComposer, NavigatorSavedSearch } from '@nitrots/nitro-renderer';
import { FC, MouseEvent } from 'react';
import { FaBolt } from 'react-icons/fa';
import { LocalizeText, SendMessageComposer } from '../../../../api';
import { Flex, Text } from '../../../../common';
+import { useNavigatorUiStore } from '../../../../hooks';
export interface NavigatorSearchSavesResultItemViewProps
{
@@ -24,7 +25,14 @@ export const NavigatorSearchSavesResultItemView: FC SendMessageComposer(new NavigatorSearchComposer(search.code.split('.').reverse()[0], search.filter));
+ const openSearch = () =>
+ {
+ const code = search.code.split('.').reverse()[0];
+ const store = useNavigatorUiStore.getState();
+
+ store.setTab(code);
+ if(search.filter) store.setFilter(search.filter);
+ };
const deleteSearch = (event: MouseEvent) =>
{
@@ -34,11 +42,10 @@ export const NavigatorSearchSavesResultItemView: FC
diff --git a/src/css/catalog/CatalogClassicView.css b/src/css/catalog/CatalogClassicView.css
index 38c082c..1f9586e 100644
--- a/src/css/catalog/CatalogClassicView.css
+++ b/src/css/catalog/CatalogClassicView.css
@@ -1103,6 +1103,39 @@
cursor: not-allowed !important;
}
+.nitro-catalog-classic-window .nitro-catalog-swf-confirm-button {
+ width: 160px !important;
+ min-width: 160px !important;
+ height: 24px !important;
+ padding: 0 8px !important;
+ border: 1px solid #000 !important;
+ border-radius: 4px !important;
+ border-image: none !important;
+ border-image-source: none !important;
+ background:
+ linear-gradient(180deg, #b6e86b 0%, #7fc828 45%, #4f9a18 100%) !important;
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.6),
+ inset 0 -2px 0 rgba(40, 80, 0, 0.35) !important;
+ color: #1f3d00 !important;
+ text-shadow: 0 1px 0 rgba(255, 255, 255, 0.45) !important;
+ font-weight: 700 !important;
+}
+
+.nitro-catalog-classic-window .nitro-catalog-swf-confirm-button:hover {
+ background:
+ linear-gradient(180deg, #c6f580 0%, #8fd836 45%, #57a81c 100%) !important;
+ filter: brightness(1.04);
+}
+
+.nitro-catalog-classic-window .nitro-catalog-swf-confirm-button:active {
+ background:
+ linear-gradient(180deg, #4f9a18 0%, #3d7a0a 100%) !important;
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.25),
+ inset 0 2px 0 rgba(40, 80, 0, 0.45) !important;
+}
+
.nitro-catalog-classic-window .nitro-catalog-swf-gift-button {
width: 160px !important;
min-width: 160px !important;
@@ -1376,9 +1409,6 @@
}
.nitro-catalog-classic-window *::-webkit-scrollbar-thumb {
- /* Grip: a single 2px #a0a0a0 stripe in an 8px-wide centered band,
- repeated every 5px (2px stripe + 3px body gap).
- Outline: 1px black border, then a 2px white inset frame inside it. */
min-height: 24px !important;
background:
url("data:image/svg+xml;utf8,") center top / 8px 5px repeat-y,
@@ -1400,9 +1430,6 @@
#bcbcbc !important;
}
-/* Arrow buttons: light grey cap with rounded OUTER corners (up button
- rounded at the top, down button rounded at the bottom), 1px black
- border, dark chevron via inline SVG. */
.nitro-catalog-classic-window *::-webkit-scrollbar-button:single-button:vertical:decrement {
display: block !important;
width: 18px !important;
@@ -1507,6 +1534,8 @@
.nitro-catalog-classic-content-shell {
padding: 6px !important;
+ overflow-x: hidden !important;
+ overflow-y: auto !important;
}
.nitro-catalog-classic-welcome {
@@ -1519,24 +1548,39 @@
.nitro-catalog-classic-stage,
.nitro-catalog-classic-stage.is-navigation-hidden {
- grid-template-columns: minmax(0, 1fr);
- grid-template-rows: auto minmax(0, 1fr);
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
width: 100%;
- }
-
- .nitro-catalog-classic-sidebar {
- max-height: 34vh;
- }
-
- .nitro-catalog-classic-layout-shell,
- .nitro-catalog-classic-default-layout {
- width: 100%;
- height: 100%;
+ height: auto;
min-height: 0;
}
+ .nitro-catalog-classic-sidebar {
+ flex: 0 0 auto;
+ max-height: 30vh;
+ }
+
+ .nitro-catalog-classic-layout-shell {
+ flex: 0 0 auto;
+ width: 100%;
+ height: auto;
+ min-height: 0;
+ overflow: visible;
+ }
+
+ .nitro-catalog-classic-layout-container {
+ flex: 0 0 auto;
+ height: auto;
+ min-height: 460px;
+ overflow: visible;
+ }
+
.nitro-catalog-classic-default-layout {
- grid-template-rows: minmax(150px, 1fr) minmax(120px, 38vh) 30px;
+ width: 100%;
+ height: 460px;
+ min-height: 460px;
+ overflow: visible;
}
.nitro-catalog-classic-grid {
diff --git a/src/css/catalog/CatalogModern.css b/src/css/catalog/CatalogModern.css
new file mode 100644
index 0000000..cabd227
--- /dev/null
+++ b/src/css/catalog/CatalogModern.css
@@ -0,0 +1,102 @@
+/* ============================================================================
+ Catalogo MODERN (Hippiehotel) — override CSS scopati a .nitro-catalog.
+ Il CSS catalogo condiviso (CatalogClassicView.css) e' SWF-style block/absolute,
+ pensato per la struttura del catalogo CLASSICO. Il catalogo modern
+ (CatalogModernView + CatalogLayoutDefaultView in ../catalog-modern/) usa un
+ layout FLEX: questi override ripristinano il flex e adattano gli elementi SOLO
+ per la finestra modern (.nitro-catalog), lasciando intatto il classico
+ (.nitro-catalog-classic-window).
+ ============================================================================ */
+
+/* Variabili SWF: definite solo su .nitro-catalog-classic-window → replicate sulla modern */
+.nitro-catalog {
+ --catalog-swf-bg: #ecece4;
+ --catalog-swf-panel: #f7f7f2;
+ --catalog-swf-panel-2: #e7e7df;
+ --catalog-swf-border: #9d9d96;
+ --catalog-swf-border-dark: #6f6f6a;
+ --catalog-swf-text: #222222;
+ --catalog-swf-muted: #666666;
+ --catalog-swf-blue: #2f8097;
+ --catalog-swf-blue-dark: #1c596c;
+ --catalog-swf-select: #63c5e9;
+ --catalog-swf-select-outer: #82d1ed;
+ --catalog-swf-bc: #ff8d00;
+ --catalog-swf-bc-outer: #ffb53c;
+}
+
+/* Layout flex (il CSS condiviso forza display:block + posizionamento assoluto) */
+.nitro-catalog .nitro-catalog-classic-default-layout {
+ display: flex !important;
+ flex-direction: column !important;
+ min-height: 0 !important;
+}
+
+.nitro-catalog .nitro-catalog-classic-offer-panel {
+ height: auto !important;
+ flex: 0 0 auto !important;
+}
+
+.nitro-catalog .nitro-catalog-classic-offer-preview,
+.nitro-catalog .nitro-catalog-classic-offer-panel > .nitro-catalog-classic-offer-preview {
+ position: relative !important;
+ height: 200px !important;
+ width: 300px !important;
+ min-width: 300px !important;
+}
+
+.nitro-catalog .nitro-catalog-classic-offer-info {
+ display: flex !important;
+ padding-left: 22px !important;
+ padding-right: 14px !important;
+ justify-content: center !important;
+}
+
+.nitro-catalog .nitro-catalog-classic-grid-shell {
+ position: static !important;
+ inset: auto !important;
+ left: auto !important;
+ top: auto !important;
+ bottom: auto !important;
+ right: auto !important;
+ width: auto !important;
+ flex: 1 1 auto !important;
+ min-height: 0 !important;
+ overflow: auto !important;
+}
+
+/* Bottoni Rotate/Toggle State: pill leggibili agli angoli del preview, icone bianche */
+.nitro-catalog .nitro-catalog-classic-preview-btn {
+ position: absolute !important;
+ top: 8px !important;
+ width: auto !important;
+ min-width: 0 !important;
+ height: auto !important;
+ min-height: 0 !important;
+ padding: 3px 9px !important;
+ background: rgba(0, 0, 0, 0.58) !important;
+ color: #ffffff !important;
+ border: 0 !important;
+ border-radius: 4px !important;
+ border-image: none !important;
+ font-size: 10px !important;
+ font-weight: 600 !important;
+ line-height: 1.2 !important;
+ gap: 4px !important;
+ z-index: 4;
+}
+
+.nitro-catalog .nitro-catalog-classic-preview-btn svg,
+.nitro-catalog .nitro-catalog-classic-preview-btn path {
+ color: #ffffff !important;
+ fill: #ffffff !important;
+}
+
+.nitro-catalog .nitro-catalog-classic-preview-rotate { left: 8px !important; right: auto !important; }
+.nitro-catalog .nitro-catalog-classic-preview-state { right: 8px !important; left: auto !important; }
+
+/* Nome doppio sul preview rimosso → resta quello nell'info (a destra) */
+.nitro-catalog .nitro-catalog-classic-preview-title { display: none !important; }
+
+/* Show pieno senza preview: niente welcome/intro → la griglia prende tutta la pagina */
+.nitro-catalog .nitro-catalog-classic-welcome { display: none !important; }
diff --git a/src/css/icons/icons.css b/src/css/icons/icons.css
index 50f390c..47c8834 100644
--- a/src/css/icons/icons.css
+++ b/src/css/icons/icons.css
@@ -92,17 +92,21 @@
}
.nitro-icon.icon-fortune-wheel {
- background-image: url("@/assets/images/toolbar/icons/game.png");
- width: 44px;
- height: 25px;
- filter: hue-rotate(300deg) saturate(1.6);
+ background-image: url("@/assets/images/toolbar/icons/fortune-wheel.svg");
+ background-size: contain;
+ background-repeat: no-repeat;
+ background-position: center;
+ width: 40px;
+ height: 40px;
}
.nitro-icon.icon-housekeeping {
- background-image: url("@/assets/images/toolbar/icons/modtools.png");
- width: 29px;
- height: 34px;
- filter: hue-rotate(140deg);
+ background-image: url("@/assets/images/toolbar/icons/housekeeping.svg");
+ background-size: contain;
+ background-repeat: no-repeat;
+ background-position: center;
+ width: 40px;
+ height: 40px;
}
.nitro-icon.icon-furnieditor {
diff --git a/src/index.tsx b/src/index.tsx
index 3b7af7d..a06be73 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -20,6 +20,7 @@ import './css/index.css';
import './css/backgrounds/BackgroundsView.css';
import './css/badges/BadgeLeaderboardView.css';
import './css/catalog/CatalogClassicView.css';
+import './css/catalog/CatalogModern.css';
import './css/emustats/EmuStatsView.css';
import './css/chat/Chats.css';