Merge branch 'Dev' into merge-duckie-main-2026-05-06

This commit is contained in:
DuckieTM
2026-05-25 18:51:48 +02:00
committed by GitHub
340 changed files with 18499 additions and 7335 deletions
@@ -2,12 +2,12 @@ import { FC, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { FaSave, FaSpinner, FaTimes, FaTrash } from 'react-icons/fa';
import { LocalizeText } from '../../../../api';
import { useCatalog } from '../../../../hooks';
import { useCatalogData } from '../../../../hooks';
import { IOfferEditData, useCatalogAdmin } from '../../CatalogAdminContext';
export const CatalogAdminOfferEditView: FC<{}> = () =>
{
const { currentPage = null } = useCatalog();
const { currentPage = null } = useCatalogData();
const catalogAdmin = useCatalogAdmin();
const editingOffer = catalogAdmin?.editingOffer ?? null;
const setEditingOffer = catalogAdmin?.setEditingOffer;
@@ -91,9 +91,10 @@ export const CatalogAdminOfferEditView: FC<{}> = () =>
orderNumber
};
const success = isNew ? await createOffer(data) : await saveOffer(data);
if(isNew) createOffer(data);
else saveOffer(data);
if(success && setEditingOffer) setEditingOffer(null);
if(setEditingOffer) setEditingOffer(null);
};
const handleDelete = () =>
@@ -1,7 +1,7 @@
import { FC, useEffect, useState } from 'react';
import { FaSave, FaSpinner, FaTimes, FaTrash } from 'react-icons/fa';
import { FaLanguage, FaSave, FaSpinner, FaTimes, FaTrash } from 'react-icons/fa';
import { CatalogType, LocalizeText } from '../../../../api';
import { useCatalog } from '../../../../hooks';
import { useCatalogData, useCatalogUiState, useTranslationActions, useTranslationState } from '../../../../hooks';
import { IPageEditData, useCatalogAdmin } from '../../CatalogAdminContext';
const LAYOUT_OPTIONS = [
@@ -15,13 +15,15 @@ const LAYOUT_OPTIONS = [
];
const MODE_OPTIONS = [
{ value: CatalogType.NORMAL, label: 'Normale' },
{ value: 'BOTH', label: 'Entrambi' }
{ value: 'NORMAL', label: 'Normal' },
{ value: 'BUILDER', label: 'Builder' },
{ value: 'BOTH', label: 'Both' }
];
export const CatalogAdminPageEditView: FC<{}> = () =>
{
const { currentPage = null, activeNodes = [], rootNode = null, currentType = CatalogType.NORMAL } = useCatalog();
const { currentPage = null, rootNode = null } = useCatalogData();
const { activeNodes = [], currentType = CatalogType.NORMAL } = useCatalogUiState();
const catalogAdmin = useCatalogAdmin();
const editingPageData = catalogAdmin?.editingPageData ?? false;
const editingRootPage = catalogAdmin?.editingRootPage ?? false;
@@ -29,17 +31,22 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
const loading = catalogAdmin?.loading ?? false;
const [ caption, setCaption ] = useState('');
const [ catalogMode, setCatalogMode ] = useState(CatalogType.NORMAL);
const [ captionSave, setCaptionSave ] = useState('');
const [ catalogMode, setCatalogMode ] = useState<string>('NORMAL');
const [ pageLayout, setPageLayout ] = useState('default_3x3');
const [ iconImage, setIconImage ] = useState(0);
const [ minRank, setMinRank ] = useState(1);
const [ visible, setVisible ] = useState('1');
const [ enabled, setEnabled ] = useState('1');
const [ orderNum, setOrderNum ] = useState(0);
// Resolve what we're editing:
// 1. editingPageNode (explicit node from sidebar click)
// 2. editingRootPage (root button)
// 3. current active page (from "Modifica Pagina" in layout)
const [ parentId, setParentId ] = useState(-1);
const [ pageText1, setPageText1 ] = useState('');
const [ showTranslate, setShowTranslate ] = useState(false);
const [ translateTargetLanguage, setTranslateTargetLanguage ] = useState('en');
const [ isTranslating, setIsTranslating ] = useState(false);
const [ translateError, setTranslateError ] = useState<string | null>(null);
const { supportedLanguages = [], languagesLoading = false } = useTranslationState();
const { translateText, ensureSupportedLanguagesLoaded } = useTranslationActions();
const targetNode = editingPageNode
? editingPageNode
: editingRootPage
@@ -61,12 +68,26 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
if(!editingPageData || !targetNode) return;
setCaption(targetNode.localization || '');
setCatalogMode(currentType === CatalogType.BUILDER ? CatalogType.BUILDER : (currentType || CatalogType.NORMAL));
setCaptionSave(targetNode.pageName || targetNode.localization || '');
setCatalogMode(currentType === CatalogType.BUILDER ? 'BUILDER' : 'NORMAL');
setPageLayout(currentPage?.layoutCode || 'default_3x3');
setIconImage(targetNode.iconId ?? 0);
setVisible(targetNode.isVisible ? '1' : '0');
setEnabled('1');
setMinRank(1);
setOrderNum(0);
const matchesLoadedPage = currentPage && targetPageId === currentPage.pageId;
const existingText1 = matchesLoadedPage && currentPage.localization
? currentPage.localization.getText(0)
: '';
setPageText1(existingText1 || '');
setShowTranslate(false);
setIsTranslating(false);
setTranslateError(null);
const wireParentId = targetNode.parentId;
setParentId(typeof wireParentId === 'number' && wireParentId !== -1
? wireParentId
: (targetNode.parent ? targetNode.parent.pageId : -1));
}, [ editingPageData, targetNode, currentPage, currentType ]);
if(!editingPageData || !targetNode) return null;
@@ -77,23 +98,65 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
{
if(!catalogAdmin?.savePage) return;
const parentNode = targetNode.parent;
const data: IPageEditData = {
pageId: targetPageId,
caption,
captionSave,
catalogMode,
pageLayout,
iconImage,
minRank,
visible,
enabled,
orderNum,
parentId: parentNode ? parentNode.pageId : -1,
parentId,
pageText1,
};
const success = await catalogAdmin.savePage(data);
catalogAdmin.savePage(data);
if(success) closeForm();
closeForm();
};
const openTranslate = () =>
{
const next = !showTranslate;
setShowTranslate(next);
setTranslateError(null);
if(next) ensureSupportedLanguagesLoaded();
};
const runTranslate = async () =>
{
if(!pageText1.trim().length)
{
setTranslateError('Nothing to translate yet.');
return;
}
if(!translateTargetLanguage)
{
setTranslateError('Pick a language first.');
return;
}
setIsTranslating(true);
setTranslateError(null);
try
{
const result = await translateText(pageText1, translateTargetLanguage);
setPageText1(result?.translatedText || pageText1);
setShowTranslate(false);
}
catch(error)
{
setTranslateError((error as Error)?.message || 'Translation failed.');
}
finally
{
setIsTranslating(false);
}
};
const handleDelete = async () =>
@@ -101,16 +164,16 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
if(!catalogAdmin?.deletePage || isRoot) return;
if(!confirm(LocalizeText('catalog.admin.delete.page.confirm', [ 'name' ], [ targetNode.localization ]))) return;
const success = await catalogAdmin.deletePage(targetPageId);
catalogAdmin.deletePage(targetPageId);
if(success) closeForm();
closeForm();
};
return (
<div className="bg-white rounded border-2 border-card-grid-item-border p-2.5 mb-2">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-bold text-primary uppercase tracking-wide">
{ isRoot ? LocalizeText('catalog.admin.edit.root') : `${ LocalizeText('catalog.admin.edit') } ${ targetNode.localization } (#${ targetPageId })` }
{ isRoot ? LocalizeText('catalog.admin.edit.root') : `${ LocalizeText('catalog.admin.edit') } ${ targetNode.localization }` }
</span>
<FaTimes className="text-muted cursor-pointer hover:text-danger text-[10px]" onClick={ closeForm } />
</div>
@@ -124,13 +187,19 @@ 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 col-span-2">
<label className="text-[9px] text-muted uppercase font-bold">Caption Save (Localisation Key)</label>
<input className={ inputClass } value={ captionSave } onChange={ e => setCaptionSave(e.target.value) } />
</div>
<div className="flex flex-col gap-0.5">
<label className="text-[9px] text-muted uppercase font-bold">Icon Image</label>
<input className={ inputClass } min={ 0 } type="number" value={ iconImage } onChange={ e => setIconImage(parseInt(e.target.value) || 0) } />
</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> }
<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>
@@ -142,6 +211,10 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
<label className="text-[9px] text-muted uppercase font-bold">{ LocalizeText('catalog.admin.order') }</label>
<input className={ inputClass } min={ 0 } type="number" value={ orderNum } onChange={ e => setOrderNum(parseInt(e.target.value) || 0) } />
</div>
<div className="flex flex-col gap-0.5">
<label className="text-[9px] text-muted uppercase font-bold">Parent ID</label>
<input className={ inputClass } disabled={ isRoot } type="number" value={ parentId } onChange={ e => setParentId(parseInt(e.target.value) || -1) } />
</div>
<div className="flex items-end gap-2 pb-0.5">
<label className="flex items-center gap-1 text-[10px] cursor-pointer">
<input className="accent-primary" checked={ visible === '1' } type="checkbox" onChange={ e => setVisible(e.target.checked ? '1' : '0') } />
@@ -152,6 +225,50 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
{ LocalizeText('catalog.admin.enabled') }
</label>
</div>
<div className="flex flex-col gap-0.5 col-span-3">
<div className="flex items-center justify-between">
<label className="text-[9px] text-muted uppercase font-bold">Page Text 1 <span className="text-muted normal-case font-normal opacity-70">(leave blank to keep current)</span></label>
<button
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-bold text-primary border border-primary/40 hover:bg-primary/10 transition-colors cursor-pointer disabled:opacity-50"
disabled={ isTranslating || !pageText1.trim().length }
title="Translate via Google Translate"
type="button"
onClick={ openTranslate }>
{ isTranslating ? <FaSpinner className="text-[8px] animate-spin" /> : <FaLanguage className="text-[10px]" /> }
Translate
</button>
</div>
{ showTranslate &&
<div className="flex items-center gap-1 mb-1 p-1 bg-gray-50 border border-card-grid-item-border rounded">
<select
className={ `${ inputClass } flex-1` }
disabled={ isTranslating || languagesLoading }
value={ translateTargetLanguage }
onChange={ e => setTranslateTargetLanguage(e.target.value) }>
{ languagesLoading && !supportedLanguages.length &&
<option value="">Loading languages</option> }
{ supportedLanguages.map(lang => (
<option key={ lang.code } value={ lang.code }>{ lang.name } ({ lang.code })</option>
)) }
</select>
<button
className="px-2 py-1 rounded text-[10px] font-bold bg-primary text-white hover:bg-secondary transition-colors cursor-pointer disabled:opacity-50"
disabled={ isTranslating || !translateTargetLanguage || !pageText1.trim().length }
type="button"
onClick={ runTranslate }>
{ isTranslating ? <FaSpinner className="text-[8px] animate-spin" /> : 'Apply' }
</button>
<button
className="px-2 py-1 rounded text-[10px] font-bold text-muted border border-card-grid-item-border hover:bg-gray-100 transition-colors cursor-pointer"
disabled={ isTranslating }
type="button"
onClick={ () => { setShowTranslate(false); setTranslateError(null); } }>
Cancel
</button>
</div> }
{ translateError && <span className="text-[9px] text-danger">{ translateError }</span> }
<textarea className={ `${ inputClass } min-h-[60px] resize-y` } value={ pageText1 } onChange={ e => setPageText1(e.target.value) } />
</div>
</div>
<div className="flex justify-between mt-2">
@@ -2,11 +2,12 @@ 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';
import { useCatalogData, useCatalogUiState } from '../../../../hooks';
export const CatalogBuildersClubStatusView: FC = () =>
{
const { currentType = CatalogType.NORMAL, furniCount = 0, furniLimit = 0, secondsLeft = 0, secondsLeftWithGrace = 0, updateTime = 0 } = useCatalog();
const { furniCount = 0, furniLimit = 0, secondsLeft = 0, secondsLeftWithGrace = 0, updateTime = 0 } = useCatalogData();
const { currentType = CatalogType.NORMAL } = useCatalogUiState();
const [ ticker, setTicker ] = useState(() => GetTickerTime());
useEffect(() =>
@@ -1,4 +1,4 @@
import { FC, useEffect, useState } from 'react';
import { FC } from 'react';
import { GetConfigurationValue } from '../../../../api';
export interface CatalogHeaderViewProps
@@ -9,12 +9,7 @@ export interface CatalogHeaderViewProps
export const CatalogHeaderView: FC<CatalogHeaderViewProps> = props =>
{
const { imageUrl = null } = props;
const [ displayImageUrl, setDisplayImageUrl ] = useState('');
useEffect(() =>
{
setDisplayImageUrl(imageUrl ?? GetConfigurationValue<string>('catalog.asset.image.url').replace('%name%', 'catalog_header_roombuilder'));
}, [ imageUrl ]);
const displayImageUrl = imageUrl ?? GetConfigurationValue<string>('catalog.asset.image.url').replace('%name%', 'catalog_header_roombuilder');
return <div className="flex justify-center items-center w-full nitro-catalog-header">
<img src={ displayImageUrl } onError={ ({ currentTarget }) =>
@@ -1,7 +1,7 @@
import { FC, useMemo } from 'react';
import { FaHeart, FaStar, FaTimes } from 'react-icons/fa';
import { ICatalogNode, LocalizeText } from '../../../../api';
import { useCatalog, useCatalogFavorites } from '../../../../hooks';
import { useCatalogActions, useCatalogData, useCatalogFavorites } from '../../../../hooks';
import { CatalogIconView } from '../catalog-icon/CatalogIconView';
interface CatalogFavoritesViewProps
@@ -13,7 +13,8 @@ export const CatalogFavoritesView: FC<CatalogFavoritesViewProps> = props =>
{
const { onClose } = props;
const { favoriteOffers, favoritePageIds, toggleFavoritePage, toggleFavoriteOffer } = useCatalogFavorites();
const { offersToNodes, activateNode, openPageByOfferId, rootNode } = useCatalog();
const { offersToNodes, rootNode } = useCatalogData();
const { activateNode, openPageByOfferId } = useCatalogActions();
const favoritePages = useMemo(() =>
{
@@ -93,13 +94,19 @@ export const CatalogFavoritesView: FC<CatalogFavoritesViewProps> = props =>
<div
key={ page.pageId }
className="group/fav flex items-center gap-2 px-1.5 py-1 bg-card-grid-item rounded border border-card-grid-item-border hover:bg-card-grid-item-active cursor-pointer transition-all duration-100"
onClick={ () => { activateNode(page.node); onClose(); } }
onClick={ () =>
{
activateNode(page.node); onClose();
} }
>
<CatalogIconView icon={ page.iconId } />
<span className="text-[11px] flex-1 truncate font-medium">{ page.name }</span>
<FaTimes
className="text-[7px] text-muted opacity-0 group-hover/fav:opacity-100 hover:text-danger transition-all cursor-pointer"
onClick={ e => { e.stopPropagation(); toggleFavoritePage(page.pageId); } }
onClick={ e =>
{
e.stopPropagation(); toggleFavoritePage(page.pageId);
} }
/>
</div>
)) }
@@ -118,7 +125,10 @@ export const CatalogFavoritesView: FC<CatalogFavoritesViewProps> = props =>
<div
key={ fav.offerId }
className="group/fav flex items-center gap-2 px-1.5 py-1 bg-card-grid-item rounded border border-card-grid-item-border hover:bg-card-grid-item-active cursor-pointer transition-all duration-100"
onClick={ () => { openPageByOfferId(fav.offerId); onClose(); } }
onClick={ () =>
{
openPageByOfferId(fav.offerId); onClose();
} }
>
{ /* Furni icon */ }
<div className="w-7 h-7 flex items-center justify-center shrink-0 bg-white rounded border border-card-grid-item-border overflow-hidden">
@@ -132,7 +142,10 @@ export const CatalogFavoritesView: FC<CatalogFavoritesViewProps> = props =>
<span className="text-[11px] flex-1 truncate font-medium">{ fav.displayName }</span>
<FaTimes
className="text-[7px] text-muted opacity-0 group-hover/fav:opacity-100 hover:text-danger transition-all cursor-pointer"
onClick={ e => { e.stopPropagation(); toggleFavoriteOffer(fav.offerId); } }
onClick={ e =>
{
e.stopPropagation(); toggleFavoriteOffer(fav.offerId);
} }
/>
</div>
)) }
@@ -4,7 +4,7 @@ import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
import { ColorUtils, LocalizeText, MessengerFriend, ProductTypeEnum, SendMessageComposer } from '../../../../api';
import { Button, Column, Flex, FormGroup, LayoutCurrencyIcon, LayoutFurniImageView, LayoutGiftTagView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
import { CatalogEvent, CatalogInitGiftEvent, CatalogPurchasedEvent } from '../../../../events';
import { useCatalog, useFriends, useMessageEvent, useUiEvent } from '../../../../hooks';
import { useFriends, useGiftConfiguration, useMessageEvent, useUiEvent } from '../../../../hooks';
import { classNames } from '../../../../layout';
let isBuyingGift = false;
@@ -25,9 +25,8 @@ export const CatalogGiftView: FC<{}> = props =>
const [ maxBoxIndex, setMaxBoxIndex ] = useState<number>(0);
const [ maxRibbonIndex, setMaxRibbonIndex ] = useState<number>(0);
const [ receiverNotFound, setReceiverNotFound ] = useState<boolean>(false);
const { catalogOptions = null } = useCatalog();
const { friends } = useFriends();
const { giftConfiguration = null } = catalogOptions;
const { data: giftConfiguration = null } = useGiftConfiguration();
const [ boxTypes, setBoxTypes ] = useState<number[]>([]);
const [ suggestions, setSuggestions ] = useState([]);
const [ isAutocompleteVisible, setIsAutocompleteVisible ] = useState(true);
@@ -133,7 +132,10 @@ export const CatalogGiftView: FC<{}> = props =>
if(isBuyingGift) return;
isBuyingGift = true;
setTimeout(() => { isBuyingGift = false; }, 10000);
setTimeout(() =>
{
isBuyingGift = false;
}, 10000);
SendMessageComposer(new PurchaseFromCatalogAsGiftComposer(pageId, offerId, extraData, receiverName, message, colourId, selectedBoxIndex, selectedRibbonIndex, showMyFace));
return;
@@ -1,10 +1,11 @@
import { FC } from 'react';
import { LocalizeText } from '../../../../api';
import { useCatalog } from '../../../../hooks';
import { useCatalogActions, useCatalogUiState } from '../../../../hooks';
export const CatalogBreadcrumbView: FC<{}> = () =>
{
const { activeNodes = [], activateNode } = useCatalog();
const { activeNodes = [] } = useCatalogUiState();
const { activateNode } = useCatalogActions();
if(!activeNodes || activeNodes.length === 0)
{
@@ -1,7 +1,7 @@
import { FC, useCallback, useRef, useState } from 'react';
import { FaArrowsAlt, FaCaretDown, FaCaretUp, FaPlus, FaStar, FaTrash } from 'react-icons/fa';
import { CatalogType, ICatalogNode, LocalizeText } from '../../../../api';
import { useCatalog, useCatalogFavorites } from '../../../../hooks';
import { useCatalogActions, useCatalogFavorites, useCatalogUiState } from '../../../../hooks';
import { useCatalogAdmin } from '../../CatalogAdminContext';
import { CatalogIconView } from '../catalog-icon/CatalogIconView';
import { CatalogNavigationSetView } from './CatalogNavigationSetView';
@@ -15,7 +15,8 @@ export interface CatalogNavigationItemViewProps
export const CatalogNavigationItemView: FC<CatalogNavigationItemViewProps> = props =>
{
const { node = null, child = false } = props;
const { activateNode = null, currentType = CatalogType.NORMAL } = useCatalog();
const { activateNode = null } = useCatalogActions();
const { currentType = CatalogType.NORMAL } = useCatalogUiState();
const catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
const { isFavoritePage, toggleFavoritePage } = useCatalogFavorites();
@@ -100,8 +101,10 @@ export const CatalogNavigationItemView: FC<CatalogNavigationItemViewProps> = pro
e.stopPropagation();
catalogAdmin.createPage({
caption: 'New Page',
captionSave: 'New Page',
catalogMode: currentType,
pageLayout: 'default_3x3',
iconImage: 0,
minRank: 1,
visible: '1',
enabled: '1',
@@ -125,8 +128,11 @@ export const CatalogNavigationItemView: FC<CatalogNavigationItemViewProps> = pro
</div> }
{ !adminMode && node.pageId > 0 &&
<FaStar
className={ `nitro-catalog-classic-navigation-favorite text-[8px] transition-all duration-100 cursor-pointer shrink-0 ${ isFav ? 'text-warning opacity-100' : 'text-muted opacity-0 group-hover/nav:opacity-100 hover:text-warning' }` }
onClick={ e => { e.stopPropagation(); toggleFavoritePage(node.pageId); } }
className={ `text-[8px] transition-all duration-100 cursor-pointer shrink-0 ${ isFav ? 'text-warning opacity-100' : 'text-muted opacity-0 group-hover/nav:opacity-100 hover:text-warning' }` }
onClick={ e =>
{
e.stopPropagation(); toggleFavoritePage(node.pageId);
} }
/> }
{ node.isBranch &&
<span className="nitro-catalog-classic-navigation-caret text-[9px] text-muted shrink-0">
@@ -1,6 +1,6 @@
import { FC } from 'react';
import { ICatalogNode } from '../../../../api';
import { useCatalog } from '../../../../hooks';
import { useCatalogData } from '../../../../hooks';
import { CatalogNavigationItemView } from './CatalogNavigationItemView';
import { CatalogNavigationSetView } from './CatalogNavigationSetView';
@@ -12,7 +12,7 @@ export interface CatalogNavigationViewProps
export const CatalogNavigationView: FC<CatalogNavigationViewProps> = props =>
{
const { node = null } = props;
const { searchResult = null } = useCatalog();
const { searchResult = null } = useCatalogData();
return (
<div className="nitro-catalog-classic-navigation-list">
@@ -3,7 +3,7 @@ import { FC, MouseEvent, useMemo, useState } from 'react';
import { FaHeart } from 'react-icons/fa';
import { CatalogType, IPurchasableOffer, Offer, ProductTypeEnum } from '../../../../../api';
import { LayoutAvatarImageView, LayoutGridItem, LayoutGridItemProps } from '../../../../../common';
import { useCatalog, useCatalogFavorites, useInventoryFurni } from '../../../../../hooks';
import { useCatalogActions, useCatalogFavorites, useCatalogUiState, useInventoryFurni } from '../../../../../hooks';
interface CatalogGridOfferViewProps extends LayoutGridItemProps
{
@@ -15,7 +15,8 @@ export const CatalogGridOfferView: FC<CatalogGridOfferViewProps> = props =>
{
const { offer = null, selectOffer = null, itemActive = false, ...rest } = props;
const [ isMouseDown, setMouseDown ] = useState(false);
const { requestOfferToMover = null, currentType = CatalogType.NORMAL } = useCatalog();
const { requestOfferToMover = null } = useCatalogActions();
const { currentType = CatalogType.NORMAL } = useCatalogUiState();
const { isVisible = false } = useInventoryFurni();
const { isFavoriteOffer, toggleFavoriteOffer } = useCatalogFavorites();
const isFav = offer ? isFavoriteOffer(offer.offerId) : false;
@@ -78,7 +79,10 @@ export const CatalogGridOfferView: FC<CatalogGridOfferViewProps> = props =>
<LayoutAvatarImageView direction={ 3 } figure={ offer.product.extraParam } headOnly={ true } /> }
<div
className={ `absolute top-0 right-0 z-10 p-0.5 cursor-pointer transition-opacity duration-100 ${ isFav ? 'opacity-100' : 'opacity-0 group-hover/tile:opacity-100' }` }
onClick={ e => { e.stopPropagation(); e.preventDefault(); toggleFavoriteOffer(offer.offerId, offer.localizationName, iconUrl); } }
onClick={ e =>
{
e.stopPropagation(); e.preventDefault(); toggleFavoriteOffer(offer.offerId, offer.localizationName, iconUrl);
} }
onMouseDown={ e => e.stopPropagation() }
>
<FaHeart className={ `text-[10px] drop-shadow transition-colors duration-100 ${ isFav ? 'text-danger' : 'text-muted hover:text-danger' }` } />
@@ -2,12 +2,13 @@ import { GetSessionDataManager, IFurnitureData } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { FaSearch, FaTimes } from 'react-icons/fa';
import { CatalogPage, CatalogType, FilterCatalogNode, FurnitureOffer, ICatalogNode, ICatalogPage, IPurchasableOffer, LocalizeText, PageLocalization, SearchResult } from '../../../../../api';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData, useCatalogUiState } from '../../../../../hooks';
export const CatalogSearchView: FC<{}> = () =>
{
const [ searchValue, setSearchValue ] = useState('');
const { currentType = null, rootNode = null, searchResult = null, setSearchResult = null, setCurrentPage = null } = useCatalog();
const { rootNode = null, searchResult = null } = useCatalogData();
const { currentType = null, setSearchResult = null, setCurrentPage = null } = useCatalogUiState();
const normalizeSearchText = (value: string) => (value || '')
.toLocaleLowerCase()
@@ -48,6 +49,7 @@ export const CatalogSearchView: FC<{}> = () =>
const name = normalizeSearchText(furniture.name || '');
const matchesSearch = name.includes(search);
const isBuyable = (furniture.purchaseOfferId > -1) || (furniture.rentOfferId > -1);
if((currentType === CatalogType.BUILDER) && (furniture.purchaseOfferId === -1) && (furniture.rentOfferId === -1))
{
@@ -56,7 +58,7 @@ export const CatalogSearchView: FC<{}> = () =>
if(matchesSearch) foundFurniLines.push(furniture.furniLine);
}
}
else if(matchesSearch)
else if(matchesSearch && isBuyable)
{
foundFurniture.push(furniture);
@@ -67,6 +69,10 @@ export const CatalogSearchView: FC<{}> = () =>
if(foundFurniture.length === 250) break;
}
else if(matchesSearch && furniture.furniLine && furniture.furniLine.length && (foundFurniLines.indexOf(furniture.furniLine) < 0))
{
foundFurniLines.push(furniture.furniLine);
}
}
const offers: IPurchasableOffer[] = [];
@@ -81,7 +87,7 @@ export const CatalogSearchView: FC<{}> = () =>
FilterCatalogNode(search, foundFurniLines, rootNode, nodes);
setSearchResult(new SearchResult(search, offers, nodes.filter(node => (node.isVisible))));
setCurrentPage((new CatalogPage(-1, 'default_3x3', new PageLocalization([], []), offers, false, 1) as ICatalogPage));
setCurrentPage((new CatalogPage(-1, 'default_3x3', new PageLocalization([], []), offers, false, 1)));
}, 300);
return () => clearTimeout(timeout);
@@ -1,7 +1,7 @@
import { FC } from 'react';
import { LocalizeText, SanitizeHtml } from '../../../../../api';
import { Column, Grid, Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData } from '../../../../../hooks';
import { CatalogBadgeSelectorWidgetView } from '../widgets/CatalogBadgeSelectorWidgetView';
import { CatalogFirstProductSelectorWidgetView } from '../widgets/CatalogFirstProductSelectorWidgetView';
import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView';
@@ -14,7 +14,7 @@ import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayoutBadgeDisplayView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const { currentOffer = null } = useCatalog();
const { currentOffer = null } = useCatalogData();
return (
<>
@@ -1,9 +1,9 @@
import { ClubOfferData, GetClubOffersMessageComposer, PurchaseFromCatalogComposer } from '@nitrots/nitro-renderer';
import { ClubOfferData, 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 { useCatalogData, useClubOffers, usePurse, useUiEvent } from '../../../../../hooks';
import { CatalogHeaderView } from '../../catalog-header/CatalogHeaderView';
import { CatalogLayoutProps } from './CatalogLayout.types';
@@ -14,12 +14,12 @@ export const CatalogLayoutBuildersClubBuyView: FC<CatalogLayoutProps> = () =>
{
const [ pendingOffer, setPendingOffer ] = useState<ClubOfferData>(null);
const [ purchaseState, setPurchaseState ] = useState(CatalogPurchaseState.NONE);
const { currentPage = null, catalogOptions = null } = useCatalog();
const { currentPage = null } = useCatalogData();
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 { data: offers = null } = useClubOffers(windowId);
const onCatalogEvent = useCallback((event: CatalogEvent) =>
{
@@ -120,11 +120,6 @@ export const CatalogLayoutBuildersClubBuyView: FC<CatalogLayoutProps> = () =>
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;
@@ -142,44 +137,45 @@ export const CatalogLayoutBuildersClubBuyView: FC<CatalogLayoutProps> = () =>
{ 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);
<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) &&
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) &&
{ (offer.priceActivityPoints > 0) &&
<Flex alignItems="center" gap={ 1 } justifyContent="end">
<Text>{ offer.priceActivityPoints }</Text>
<LayoutCurrencyIcon type={ offer.priceActivityPointsType } />
</Flex> }
</div>
</LayoutGridItem>
);
}) }
</div>
</LayoutGridItem>
);
}) }
</Column>
</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 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> }
@@ -202,7 +198,7 @@ export const CatalogLayoutBuildersClubBuyView: FC<CatalogLayoutProps> = () =>
</Flex>
{ getPurchaseButton() }
</Column> }
</Column>
</Column>
</Grid>
</div>
);
@@ -3,7 +3,7 @@ import { FC, useMemo, useState } from 'react';
import { FaFillDrip } from 'react-icons/fa';
import { IPurchasableOffer, SanitizeHtml } from '../../../../../api';
import { AutoGrid, Button, Column, Grid, LayoutGridItem, Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData, useCatalogUiState } from '../../../../../hooks';
import { CatalogGridOfferView } from '../common/CatalogGridOfferView';
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
import { CatalogLimitedItemWidgetView } from '../widgets/CatalogLimitedItemWidgetView';
@@ -22,7 +22,8 @@ export const CatalogLayoutColorGroupingView: FC<CatalogLayoutColorGroupViewProps
{
const { page = null } = props;
const [ colorableItems, setColorableItems ] = useState<Map<string, number[]>>(new Map<string, number[]>());
const { currentOffer = null, setCurrentOffer = null } = useCatalog();
const { currentOffer = null } = useCatalogData();
const { setCurrentOffer = null } = useCatalogUiState();
const [ colorsShowing, setColorsShowing ] = useState<boolean>(false);
const sortByColorIndex = (a: IPurchasableOffer, b: IPurchasableOffer) =>
@@ -117,7 +117,10 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
if(!prefixText.length) return;
const newColors: Record<number, string> = {};
[ ...prefixText ].forEach((_, i) => { newColors[i] = customColorInput; });
[ ...prefixText ].forEach((_, i) =>
{
newColors[i] = customColorInput;
});
setLetterColors(newColors);
};
@@ -222,7 +225,10 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
<Picker
data={ data }
locale="it"
onEmojiSelect={ (emoji: { native: string }) => { setSelectedIcon(emoji.native); setShowIconPicker(false); } }
onEmojiSelect={ (emoji: { native: string }) =>
{
setSelectedIcon(emoji.native); setShowIconPicker(false);
} }
theme="dark"
previewPosition="none"
skinTonePosition="search"
@@ -268,7 +274,10 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
borderRight: '1px solid rgba(0,0,0,0.1)',
opacity: colorMode === 'single' ? 1 : 0.6
} }
onClick={ () => { setColorMode('single'); setSelectedLetterIndex(null); } }>
onClick={ () =>
{
setColorMode('single'); setSelectedLetterIndex(null);
} }>
{ LocalizeText('catalog.prefix.color.single') }
</button>
<button
@@ -277,7 +286,10 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
background: colorMode === 'perLetter' ? 'rgba(59,130,246,0.25)' : 'rgba(0,0,0,0.1)',
opacity: colorMode === 'perLetter' ? 1 : 0.6
} }
onClick={ () => { setColorMode('perLetter'); if(prefixText.length > 0) setSelectedLetterIndex(0); } }>
onClick={ () =>
{
setColorMode('perLetter'); if(prefixText.length > 0) setSelectedLetterIndex(0);
} }>
{ LocalizeText('catalog.prefix.color.per.letter') }
</button>
</div>
@@ -328,7 +340,10 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
zIndex: isSelected ? 10 : 1,
boxShadow: isSelected ? '0 0 8px rgba(59,130,246,0.3)' : 'none'
} }
onClick={ () => { setSelectedLetterIndex(i); setCustomColorInput(charColor); } }>
onClick={ () =>
{
setSelectedLetterIndex(i); setCustomColorInput(charColor);
} }>
<span className="text-sm font-black" style={ { color: charColor } }>
{ char }
</span>
@@ -2,7 +2,7 @@ import { FC } from 'react';
import { FaEdit, FaPlus } from 'react-icons/fa';
import { GetConfigurationValue, LocalizeText, ProductTypeEnum, SanitizeHtml } from '../../../../../api';
import { Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData } from '../../../../../hooks';
import { useCatalogAdmin } from '../../../CatalogAdminContext';
import { CatalogHeaderView } from '../../catalog-header/CatalogHeaderView';
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
@@ -17,7 +17,7 @@ import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayoutDefaultView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const { currentOffer = null, currentPage = null } = useCatalog();
const { currentOffer = null, currentPage = null } = useCatalogData();
const catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
@@ -28,7 +28,10 @@ export const CatalogLayoutDefaultView: FC<CatalogLayoutProps> = props =>
<div className="flex gap-2 nitro-catalog-classic-default-admin">
<button
className="flex items-center gap-1 text-[10px] text-primary hover:text-dark transition-colors cursor-pointer"
onClick={ () => { catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true); } }
onClick={ () =>
{
catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true);
} }
>
<FaEdit className="text-[10px]" /> { LocalizeText('catalog.admin.edit.page') }
</button>
@@ -1,7 +1,7 @@
import { FC } from 'react';
import { SanitizeHtml } from '../../../../../api';
import { Column, Grid, Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData } from '../../../../../hooks';
import { CatalogGuildBadgeWidgetView } from '../widgets/CatalogGuildBadgeWidgetView';
import { CatalogGuildSelectorWidgetView } from '../widgets/CatalogGuildSelectorWidgetView';
import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView';
@@ -13,7 +13,7 @@ import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayouGuildCustomFurniView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const { currentOffer = null } = useCatalog();
const { currentOffer = null } = useCatalogData();
return (
<Grid>
@@ -1,8 +1,7 @@
import { CatalogGroupsComposer } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { SanitizeHtml, SendMessageComposer } from '../../../../../api';
import { FC, useState } from 'react';
import { SanitizeHtml } from '../../../../../api';
import { Column, Grid, Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData, useCatalogUiState, useUserGroups } from '../../../../../hooks';
import { CatalogFirstProductSelectorWidgetView } from '../widgets/CatalogFirstProductSelectorWidgetView';
import { CatalogGuildSelectorWidgetView } from '../widgets/CatalogGuildSelectorWidgetView';
import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView';
@@ -13,13 +12,9 @@ export const CatalogLayouGuildForumView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const [ selectedGroupIndex, setSelectedGroupIndex ] = useState<number>(0);
const { currentOffer = null, setCurrentOffer = null, catalogOptions = null } = useCatalog();
const { groups = null } = catalogOptions;
useEffect(() =>
{
SendMessageComposer(new CatalogGroupsComposer());
}, [ page ]);
const { currentOffer = null } = useCatalogData();
const { setCurrentOffer = null } = useCatalogUiState();
const { data: groups = null } = useUserGroups();
return (
<>
@@ -1,8 +1,9 @@
import { GetRoomAdPurchaseInfoComposer, GetUserEventCatsMessageComposer, PurchaseRoomAdMessageComposer, RoomAdPurchaseInfoEvent, RoomEntryData } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { LocalizeText, SendMessageComposer } from '../../../../../api';
import { useNitroQuery } from '../../../../../api/nitro-query';
import { Button, Column, Text } from '../../../../../common';
import { useCatalog, useMessageEvent, useNavigator, useRoomPromote } from '../../../../../hooks';
import { useCatalogUiState, useNavigator, useRoomPromote } from '../../../../../hooks';
import { NitroInput } from '../../../../../layout';
import { CatalogLayoutProps } from './CatalogLayout.types';
@@ -14,13 +15,20 @@ export const CatalogLayoutRoomAdsView: FC<CatalogLayoutProps> = props =>
const [ eventName, setEventName ] = useState<string>('');
const [ eventDesc, setEventDesc ] = useState<string>('');
const [ roomId, setRoomId ] = useState<number>(-1);
const [ availableRooms, setAvailableRooms ] = useState<RoomEntryData[]>([]);
const [ extended, setExtended ] = useState<boolean>(false);
const [ categoryId, setCategoryId ] = useState<number>(1);
const { categories = null } = useNavigator();
const { setIsVisible = null } = useCatalog();
const { setIsVisible = null } = useCatalogUiState();
const { promoteInformation, isExtended, setIsExtended } = useRoomPromote();
const { data: availableRooms = [] } = useNitroQuery<RoomAdPurchaseInfoEvent, RoomEntryData[]>({
key: [ 'nitro', 'catalog', 'room-ad-purchase-info' ],
request: () => new GetRoomAdPurchaseInfoComposer(),
parser: RoomAdPurchaseInfoEvent,
select: e => e.getParser()?.rooms ?? [],
staleTime: 60_000
});
useEffect(() =>
{
if(isExtended)
@@ -62,18 +70,8 @@ export const CatalogLayoutRoomAdsView: FC<CatalogLayoutProps> = props =>
resetData();
};
useMessageEvent<RoomAdPurchaseInfoEvent>(RoomAdPurchaseInfoEvent, event =>
{
const parser = event.getParser();
if(!parser) return;
setAvailableRooms(parser.rooms);
});
useEffect(() =>
{
SendMessageComposer(new GetRoomAdPurchaseInfoComposer());
// TODO: someone needs to fix this for morningstar
SendMessageComposer(new GetUserEventCatsMessageComposer());
}, []);
@@ -2,7 +2,7 @@ import { GetOfficialSongIdMessageComposer, GetSoundManager, MusicPriorities, Off
import { FC, useEffect, useState } from 'react';
import { GetConfigurationValue, LocalizeText, ProductTypeEnum, SanitizeHtml, SendMessageComposer } from '../../../../../api';
import { Button, Column, Grid, LayoutImage, Text } from '../../../../../common';
import { useCatalog, useMessageEvent } from '../../../../../hooks';
import { useCatalogData, useMessageEvent } from '../../../../../hooks';
import { CatalogHeaderView } from '../../catalog-header/CatalogHeaderView';
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView';
@@ -18,7 +18,7 @@ export const CatalogLayoutSoundMachineView: FC<CatalogLayoutProps> = props =>
const { page = null } = props;
const [ songId, setSongId ] = useState(-1);
const [ officialSongId, setOfficialSongId ] = useState('');
const { currentOffer = null, currentPage = null } = useCatalog();
const { currentOffer = null, currentPage = null } = useCatalogData();
const previewSong = (previewSongId: number) => GetSoundManager().musicController?.playSong(previewSongId, MusicPriorities.PRIORITY_PURCHASE_PREVIEW, 15, 0, 0, 0);
@@ -1,7 +1,7 @@
import { FC, useEffect } from 'react';
import { SanitizeHtml } from '../../../../../api';
import { Column, Grid, Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData } from '../../../../../hooks';
import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView';
import { CatalogSpacesWidgetView } from '../widgets/CatalogSpacesWidgetView';
import { CatalogTotalPriceWidget } from '../widgets/CatalogTotalPriceWidget';
@@ -11,7 +11,7 @@ import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayoutSpacesView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const { currentOffer = null, roomPreviewer = null } = useCatalog();
const { currentOffer = null, roomPreviewer = null } = useCatalogData();
useEffect(() =>
{
@@ -2,7 +2,7 @@ import { FC, useEffect, useState } from 'react';
import { FaEdit, FaPen, FaPlus, FaTrophy } from 'react-icons/fa';
import { LocalizeText, ProductTypeEnum, SanitizeHtml } from '../../../../../api';
import { Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData, useCatalogUiState } from '../../../../../hooks';
import { useCatalogAdmin } from '../../../CatalogAdminContext';
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView';
@@ -15,7 +15,8 @@ export const CatalogLayoutTrophiesView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const [ trophyText, setTrophyText ] = useState<string>('');
const { currentOffer = null, setPurchaseOptions = null } = useCatalog();
const { currentOffer = null } = useCatalogData();
const { setPurchaseOptions = null } = useCatalogUiState();
const catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
@@ -42,7 +43,10 @@ export const CatalogLayoutTrophiesView: FC<CatalogLayoutProps> = props =>
<div className="flex gap-2">
<button
className="flex items-center gap-1 text-[10px] text-primary hover:text-dark transition-colors cursor-pointer"
onClick={ () => { catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true); } }
onClick={ () =>
{
catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true);
} }
>
<FaEdit className="text-[10px]" /> { LocalizeText('catalog.admin.edit.page') }
</button>
@@ -1,20 +1,30 @@
import { ClubOfferData, GetClubOffersMessageComposer, PurchaseFromCatalogComposer } from '@nitrots/nitro-renderer';
import { ClubOfferData, GiftReceiverNotFoundEvent, PurchaseFromCatalogAsGiftComposer, PurchaseFromCatalogComposer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { CatalogPurchaseState, LocalizeText, SanitizeHtml, SendMessageComposer } from '../../../../../api';
import { AutoGrid, Button, Column, Flex, Grid, LayoutCurrencyIcon, LayoutGridItem, LayoutLoadingSpinnerView, Text } from '../../../../../common';
import { AutoGrid, Button, Column, Flex, Grid, LayoutCurrencyIcon, LayoutLoadingSpinnerView, Text } from '../../../../../common';
import { CatalogEvent, CatalogPurchaseFailureEvent, CatalogPurchasedEvent } from '../../../../../events';
import { useCatalog, usePurse, useUiEvent } from '../../../../../hooks';
import { useCatalogData, useClubOffers, useMessageEvent, usePurse, useUiEvent, useUserDataSnapshot } from '../../../../../hooks';
import { CatalogLayoutProps } from './CatalogLayout.types';
const VIP_WINDOW_ID = 1;
export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
{
const [ pendingOffer, setPendingOffer ] = useState<ClubOfferData>(null);
const [ purchaseState, setPurchaseState ] = useState(CatalogPurchaseState.NONE);
const { currentPage = null, catalogOptions = null } = useCatalog();
const [ giftMode, setGiftMode ] = useState(false);
const [ giftRecipient, setGiftRecipient ] = useState('');
const [ giftError, setGiftError ] = useState<string | null>(null);
const [ giftSuccess, setGiftSuccess ] = useState(false);
const { currentPage = null } = useCatalogData();
const { purse = null, getCurrencyAmount = null } = usePurse();
const { clubOffers = null, clubOffersByWindowId = null } = (catalogOptions || {});
const offers = clubOffersByWindowId?.[1] || clubOffers;
const { data: offers = null } = useClubOffers(VIP_WINDOW_ID);
const { userName: ownUserName = '' } = useUserDataSnapshot();
const isPurchasingRef = useRef<boolean>(false);
const wasGiftPurchaseRef = useRef<boolean>(false);
const giftSuccessTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isSelfGift = giftMode && !!ownUserName && giftRecipient.trim().toLowerCase() === ownUserName.toLowerCase();
const onCatalogEvent = useCallback((event: CatalogEvent) =>
{
@@ -23,9 +33,20 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
case CatalogPurchasedEvent.PURCHASE_SUCCESS:
isPurchasingRef.current = false;
setPurchaseState(CatalogPurchaseState.NONE);
setGiftError(null);
if(wasGiftPurchaseRef.current)
{
wasGiftPurchaseRef.current = false;
setGiftRecipient('');
setGiftMode(false);
setGiftSuccess(true);
if(giftSuccessTimerRef.current) clearTimeout(giftSuccessTimerRef.current);
giftSuccessTimerRef.current = setTimeout(() => setGiftSuccess(false), 3500);
}
return;
case CatalogPurchaseFailureEvent.PURCHASE_FAILED:
isPurchasingRef.current = false;
wasGiftPurchaseRef.current = false;
setPurchaseState(CatalogPurchaseState.FAILED);
return;
}
@@ -34,6 +55,21 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
useUiEvent(CatalogPurchasedEvent.PURCHASE_SUCCESS, onCatalogEvent);
useUiEvent(CatalogPurchaseFailureEvent.PURCHASE_FAILED, onCatalogEvent);
useEffect(() => () =>
{
if(giftSuccessTimerRef.current) clearTimeout(giftSuccessTimerRef.current);
}, []);
const handleGiftReceiverNotFound = useCallback(() =>
{
if(!isPurchasingRef.current) return;
isPurchasingRef.current = false;
setPurchaseState(CatalogPurchaseState.NONE);
setGiftError(LocalizeText('catalog.gift_wrapping.receiver_not_found.title'));
}, []);
useMessageEvent<GiftReceiverNotFoundEvent>(GiftReceiverNotFoundEvent, handleGiftReceiverNotFound);
const getOfferText = useCallback((offer: ClubOfferData) =>
{
let offerText = '';
@@ -88,16 +124,39 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
const purchaseSubscription = useCallback(() =>
{
if(!pendingOffer || isPurchasingRef.current) return;
if(giftMode && !giftRecipient.trim()) return;
if(isSelfGift) return;
isPurchasingRef.current = true;
wasGiftPurchaseRef.current = giftMode;
setPurchaseState(CatalogPurchaseState.PURCHASE);
SendMessageComposer(new PurchaseFromCatalogComposer(currentPage.pageId, pendingOffer.offerId, null, 1));
}, [ pendingOffer, currentPage ]);
setGiftError(null);
setGiftSuccess(false);
if(giftMode)
{
SendMessageComposer(new PurchaseFromCatalogAsGiftComposer(currentPage.pageId, pendingOffer.offerId, '', giftRecipient.trim(), '', 0, 0, 0, false));
}
else
{
SendMessageComposer(new PurchaseFromCatalogComposer(currentPage.pageId, pendingOffer.offerId, null, 1));
}
}, [ pendingOffer, currentPage, giftMode, giftRecipient, isSelfGift ]);
const setOffer = useCallback((offer: ClubOfferData) =>
{
setPurchaseState(CatalogPurchaseState.NONE);
setPendingOffer(offer);
setGiftError(null);
setGiftSuccess(false);
if(!offer?.giftable) setGiftMode(false);
}, []);
const onGiftRecipientChange = useCallback((value: string) =>
{
setGiftRecipient(value);
setGiftError(null);
setGiftSuccess(false);
}, []);
const getPurchaseButton = useCallback(() =>
@@ -114,24 +173,22 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
return <Button fullWidth variant="danger">{ LocalizeText('catalog.alert.notenough.activitypoints.title.' + pendingOffer.priceActivityPointsType) }</Button>;
}
const giftBlocked = giftMode && (!giftRecipient.trim() || isSelfGift);
const buyLabel = giftMode ? LocalizeText('catalog.gift_wrapping.give_gift') : LocalizeText('buy');
switch(purchaseState)
{
case CatalogPurchaseState.CONFIRM:
return <Button fullWidth variant="warning" onClick={ purchaseSubscription }>{ LocalizeText('catalog.marketplace.confirm_title') }</Button>;
return <Button disabled={ giftBlocked } fullWidth variant="warning" onClick={ purchaseSubscription }>{ 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>;
return <Button disabled={ giftBlocked } fullWidth variant="success" onClick={ () => setPurchaseState(CatalogPurchaseState.CONFIRM) }>{ buyLabel }</Button>;
}
}, [ pendingOffer, purchaseState, purchaseSubscription, getCurrencyAmount ]);
useEffect(() =>
{
if(!offers) SendMessageComposer(new GetClubOffersMessageComposer(1));
}, [ offers ]);
}, [ pendingOffer, purchaseState, purchaseSubscription, getCurrencyAmount, giftMode, giftRecipient, isSelfGift ]);
return (
<Grid>
@@ -139,25 +196,29 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
<AutoGrid className="nitro-catalog-layout-vip-buy-grid" columnCount={ 1 }>
{ offers && (offers.length > 0) && offers.map((offer, index) =>
{
const isActive = (pendingOffer === offer);
return (
<LayoutGridItem key={ index } alignItems="center" center={ false } className="p-1" column={ false } itemActive={ pendingOffer === offer } justifyContent="between" onClick={ () => setOffer(offer) }>
<i className="icon-hc-banner" />
<Column gap={ 0 } justifyContent="end">
<Text textEnd>{ getOfferText(offer) }</Text>
<Flex gap={ 1 } justifyContent="end">
{ (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> }
</Flex>
</Column>
</LayoutGridItem>
<div key={ index } className={ 'nitro-vip-buy-offer flex flex-col gap-1.5 p-2 rounded-md border-2 cursor-pointer ' + (isActive ? 'active border-[#7a5500] bg-[#ffe066]' : 'border-[#b48a18] bg-[#fffbe7] hover:bg-[#fff5c4] hover:border-[#9c7610]') } onClick={ () => setOffer(offer) }>
<div className="vip-offer-header flex items-center gap-2 pb-1.5 border-b border-dashed border-[#b48a18]">
<span className="vip-offer-banner inline-flex items-center justify-center shrink-0 w-[34px] h-[20px]">
<i className="nitro-icon icon-hc-banner" style={ { width: '34px', height: '20px', backgroundSize: 'contain', backgroundRepeat: 'no-repeat', backgroundPosition: 'center' } } />
</span>
<span className="vip-offer-title flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-bold text-[1.05rem] leading-tight text-[#2c2a25]">{ getOfferText(offer) }</span>
</div>
<div className="vip-offer-prices flex flex-col gap-1">
{ (offer.priceCredits > 0) &&
<span className="vip-offer-price flex items-center gap-1.5 font-bold text-[0.95rem] leading-tight text-[#4a473e] whitespace-nowrap">
<LayoutCurrencyIcon type={ -1 } />
<span>{ offer.priceCredits }</span>
</span> }
{ (offer.priceActivityPoints > 0) &&
<span className="vip-offer-price flex items-center gap-1.5 font-bold text-[0.95rem] leading-tight text-[#4a473e] whitespace-nowrap">
<LayoutCurrencyIcon type={ offer.priceActivityPointsType } />
<span>{ offer.priceActivityPoints }</span>
</span> }
</div>
</div>
);
}) }
</AutoGrid>
@@ -172,7 +233,7 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
<Column fullWidth grow justifyContent="end">
<Flex alignItems="end">
<Column grow gap={ 0 }>
<Text fontWeight="bold">{ getPurchaseHeader() }</Text>
<Text fontWeight="bold">{ giftMode ? LocalizeText('catalog.purchase_confirmation.gift') : getPurchaseHeader() }</Text>
<Text>{ getPurchaseValidUntil() }</Text>
</Column>
<div className="flex flex-col gap-1">
@@ -188,6 +249,28 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
</Flex> }
</div>
</Flex>
{ pendingOffer.giftable &&
<Column className="mt-1" gap={ 1 }>
<Flex alignItems="center" gap={ 2 }>
<label className="flex items-center gap-1 cursor-pointer text-sm">
<input checked={ giftMode } className="cursor-pointer" type="checkbox" onChange={ event => { setGiftMode(event.target.checked); setGiftError(null); setGiftSuccess(false); } } />
<span>{ LocalizeText('catalog.purchase_confirmation.gift') }</span>
</label>
{ giftMode &&
<input
className="flex-1 min-w-0 border border-[#b48a18] bg-white rounded px-2 py-1 text-sm"
placeholder={ LocalizeText('catalog.gift_wrapping.receiver') }
type="text"
value={ giftRecipient }
onChange={ event => onGiftRecipientChange(event.target.value) } /> }
</Flex>
{ giftMode && isSelfGift &&
<Text className="text-[#b00020] text-xs">{ LocalizeText('catalog.gift_wrapping.cannot_send_to_self') }</Text> }
{ giftMode && giftError && !isSelfGift &&
<Text className="text-[#b00020] text-xs">{ giftError }</Text> }
{ giftSuccess &&
<Text className="text-[#1f7a1f] text-sm font-bold">{ LocalizeText('catalog.gift_wrapping.gift_sent') }</Text> }
</Column> }
{ getPurchaseButton() }
</Column> }
</Column>
@@ -1,7 +1,7 @@
import { CreateLinkEvent, FrontPageItem } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect } from 'react';
import { Column, Grid } from '../../../../../../common';
import { useCatalog } from '../../../../../../hooks';
import { useCatalogData } from '../../../../../../hooks';
import { CatalogRedeemVoucherView } from '../../common/CatalogRedeemVoucherView';
import { CatalogLayoutProps } from '../CatalogLayout.types';
import { CatalogLayoutFrontPageItemView } from './CatalogLayoutFrontPageItemView';
@@ -9,7 +9,7 @@ import { CatalogLayoutFrontPageItemView } from './CatalogLayoutFrontPageItemView
export const CatalogLayoutFrontpage4View: FC<CatalogLayoutProps> = props =>
{
const { page = null, hideNavigation = null } = props;
const { frontPageItems = [] } = useCatalog();
const { frontPageItems = [] } = useCatalogData();
const selectItem = useCallback((item: FrontPageItem) =>
{
@@ -1,9 +1,9 @@
import { GetMarketplaceConfigurationMessageComposer, MakeOfferMessageComposer, MarketplaceConfigurationEvent } from '@nitrots/nitro-renderer';
import { MakeOfferMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { FurnitureItem, LocalizeText, ProductTypeEnum, SendMessageComposer } from '../../../../../../api';
import { Button, Column, Grid, LayoutFurniImageView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../../../common';
import { CatalogPostMarketplaceOfferEvent } from '../../../../../../events';
import { useCatalog, useMessageEvent, useNotification, useUiEvent } from '../../../../../../hooks';
import { useMarketplaceConfiguration, useNotification, useUiEvent } from '../../../../../../hooks';
import { NitroInput } from '../../../../../../layout';
let isPostingMarketplaceOffer = false;
@@ -13,8 +13,7 @@ export const MarketplacePostOfferView: FC<{}> = props =>
const [ item, setItem ] = useState<FurnitureItem>(null);
const [ askingPrice, setAskingPrice ] = useState(0);
const [ tempAskingPrice, setTempAskingPrice ] = useState('0');
const { catalogOptions = null, setCatalogOptions = null } = useCatalog();
const { marketplaceConfiguration = null } = catalogOptions;
const { data: marketplaceConfiguration = null } = useMarketplaceConfiguration({ enabled: !!item });
const { showConfirm = null } = useNotification();
const updateAskingPrice = (price: string) =>
@@ -28,29 +27,8 @@ export const MarketplacePostOfferView: FC<{}> = props =>
setAskingPrice(parseInt(price));
};
useMessageEvent<MarketplaceConfigurationEvent>(MarketplaceConfigurationEvent, event =>
{
const parser = event.getParser();
setCatalogOptions(prevValue =>
{
const newValue = { ...prevValue };
newValue.marketplaceConfiguration = parser;
return newValue;
});
});
useUiEvent<CatalogPostMarketplaceOfferEvent>(CatalogPostMarketplaceOfferEvent.POST_MARKETPLACE, event => setItem(event.item));
useEffect(() =>
{
if(!item || marketplaceConfiguration) return;
SendMessageComposer(new GetMarketplaceConfigurationMessageComposer());
}, [ item, marketplaceConfiguration ]);
useEffect(() =>
{
if(!item) return;
@@ -1,10 +1,10 @@
import { ApproveNameMessageComposer, ApproveNameMessageEvent, ColorConverter, GetSellablePetPalettesComposer, PurchaseFromCatalogComposer, SellablePetPaletteData } from '@nitrots/nitro-renderer';
import { ApproveNameMessageComposer, ApproveNameMessageEvent, ColorConverter, PurchaseFromCatalogComposer, SellablePetPaletteData } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { FaCheck, FaEdit, FaFillDrip, FaPaw, FaPlus, FaTimes } from 'react-icons/fa';
import { DispatchUiEvent, GetPetAvailableColors, GetPetIndexFromLocalization, LocalizeText, SanitizeHtml, SendMessageComposer } from '../../../../../../api';
import { LayoutGridItem, LayoutPetImageView } from '../../../../../../common';
import { CatalogPurchaseFailureEvent } from '../../../../../../events';
import { useCatalog, useMessageEvent } from '../../../../../../hooks';
import { useCatalogData, useCatalogUiState, useMessageEvent, useSellablePetPalette } from '../../../../../../hooks';
import { useCatalogAdmin } from '../../../../CatalogAdminContext';
import { CatalogAddOnBadgeWidgetView } from '../../widgets/CatalogAddOnBadgeWidgetView';
import { CatalogTotalPriceWidget } from '../../widgets/CatalogTotalPriceWidget';
@@ -23,10 +23,12 @@ export const CatalogLayoutPetView: FC<CatalogLayoutProps> = props =>
const [ petName, setPetName ] = useState('');
const [ approvalPending, setApprovalPending ] = useState(true);
const [ approvalResult, setApprovalResult ] = useState(-1);
const { currentOffer = null, setCurrentOffer = null, setPurchaseOptions = null, catalogOptions = null, roomPreviewer = null } = useCatalog();
const { currentOffer = null, roomPreviewer = null } = useCatalogData();
const { setCurrentOffer = null, setPurchaseOptions = null } = useCatalogUiState();
const catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
const { petPalettes = null } = catalogOptions;
const breed: string = (currentOffer?.product?.productData?.type as unknown as string) ?? '';
const { data: petPalette = null } = useSellablePetPalette(breed);
const getColor = useMemo(() =>
{
@@ -129,39 +131,25 @@ export const CatalogLayoutPetView: FC<CatalogLayoutProps> = props =>
useEffect(() =>
{
if(!currentOffer) return;
const productData = currentOffer.product.productData;
if(!productData) return;
if(petPalettes)
if(!currentOffer || !petPalette)
{
for(const paletteData of petPalettes)
{
if(paletteData.breed !== productData.type) continue;
const palettes: SellablePetPaletteData[] = [];
for(const palette of paletteData.palettes)
{
if(!palette.sellable) continue;
palettes.push(palette);
}
setSelectedPaletteIndex((palettes.length ? 0 : -1));
setSellablePalettes(palettes);
return;
}
setSelectedPaletteIndex(-1);
setSellablePalettes([]);
return;
}
setSelectedPaletteIndex(-1);
setSellablePalettes([]);
const palettes: SellablePetPaletteData[] = [];
SendMessageComposer(new GetSellablePetPalettesComposer(productData.type));
}, [ currentOffer, petPalettes ]);
for(const palette of petPalette.palettes)
{
if(!palette.sellable) continue;
palettes.push(palette);
}
setSelectedPaletteIndex(palettes.length ? 0 : -1);
setSellablePalettes(palettes);
}, [ currentOffer, petPalette ]);
useEffect(() =>
{
@@ -202,7 +190,10 @@ export const CatalogLayoutPetView: FC<CatalogLayoutProps> = props =>
<div className="flex gap-2">
<button
className="flex items-center gap-1 text-[10px] text-primary hover:text-dark transition-colors cursor-pointer"
onClick={ () => { catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true); } }
onClick={ () =>
{
catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true);
} }
>
<FaEdit className="text-[10px]" /> { LocalizeText('catalog.admin.edit.page') }
</button>
@@ -2,7 +2,7 @@ import { SelectClubGiftComposer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useMemo } from 'react';
import { LocalizeText, SendMessageComposer } from '../../../../../../api';
import { AutoGrid, Text } from '../../../../../../common';
import { useCatalog, useNotification, usePurse } from '../../../../../../hooks';
import { useClubGifts, useNotification, usePurse } from '../../../../../../hooks';
import { CatalogLayoutProps } from '../CatalogLayout.types';
import { VipGiftItem } from './VipGiftItemView';
@@ -11,8 +11,7 @@ let isSelectingGift = false;
export const CatalogLayoutVipGiftsView: FC<CatalogLayoutProps> = props =>
{
const { purse = null } = usePurse();
const { catalogOptions = null, setCatalogOptions = null } = useCatalog();
const { clubGifts = null } = catalogOptions;
const { data: clubGifts = null } = useClubGifts();
const { showConfirm = null } = useNotification();
const giftsAvailable = useCallback(() =>
@@ -36,34 +35,32 @@ export const CatalogLayoutVipGiftsView: FC<CatalogLayoutProps> = props =>
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));
setCatalogOptions(prevValue =>
{
prevValue.clubGifts.giftsAvailable--;
return { ...prevValue };
});
setTimeout(() => isSelectingGift = false, 5000);
}, null);
}, [ setCatalogOptions, showConfirm ]);
}, [ showConfirm ]);
const sortGifts = useMemo(() =>
{
let gifts = clubGifts.offers.sort((a,b) =>
{
return clubGifts.getOfferExtraData(a.offerId).daysRequired - clubGifts.getOfferExtraData(b.offerId).daysRequired;
});
return gifts;
},[ clubGifts ]);
if(!clubGifts) return [];
return [ ...clubGifts.offers ].sort((a, b) =>
(clubGifts.getOfferExtraData(a.offerId).daysRequired - clubGifts.getOfferExtraData(b.offerId).daysRequired)
);
}, [ clubGifts ]);
return (
<>
<Text shrink truncate fontWeight="bold">{ giftsAvailable() }</Text>
<AutoGrid className="nitro-catalog-layout-vip-gifts-grid" columnCount={ 1 }>
{ (clubGifts.offers.length > 0) && sortGifts.map(offer => <VipGiftItem key={ offer.offerId } daysRequired={ clubGifts.getOfferExtraData(offer.offerId).daysRequired } isAvailable={ (clubGifts.getOfferExtraData(offer.offerId).isSelectable && (clubGifts.giftsAvailable > 0)) } offer={ offer } onSelect={ selectGift }/>) }
{ clubGifts && (clubGifts.offers.length > 0) && sortGifts.map(offer => <VipGiftItem key={ offer.offerId } daysRequired={ clubGifts.getOfferExtraData(offer.offerId).daysRequired } isAvailable={ (clubGifts.getOfferExtraData(offer.offerId).isSelectable && (clubGifts.giftsAvailable > 0)) } offer={ offer } onSelect={ selectGift }/>) }
</AutoGrid>
</>
);
@@ -1,6 +1,6 @@
import { FC } from 'react';
import { BaseProps, LayoutBadgeImageView } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData } from '../../../../../hooks';
interface CatalogAddOnBadgeWidgetViewProps extends BaseProps<HTMLDivElement>
{
@@ -10,7 +10,7 @@ interface CatalogAddOnBadgeWidgetViewProps extends BaseProps<HTMLDivElement>
export const CatalogAddOnBadgeWidgetView: FC<CatalogAddOnBadgeWidgetViewProps> = props =>
{
const { ...rest } = props;
const { currentOffer = null } = useCatalog();
const { currentOffer = null } = useCatalogData();
if(!currentOffer || !currentOffer.badgeCode || !currentOffer.badgeCode.length) return null;
@@ -1,7 +1,7 @@
import { StringDataType } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useState } from 'react';
import { AutoGrid, AutoGridProps, LayoutBadgeImageView, LayoutGridItem } from '../../../../../common';
import { useCatalog, useInventoryBadges } from '../../../../../hooks';
import { useCatalogData, useCatalogUiState, useInventoryBadges } from '../../../../../hooks';
const EXCLUDED_BADGE_CODES: string[] = [];
@@ -15,7 +15,8 @@ export const CatalogBadgeSelectorWidgetView: FC<CatalogBadgeSelectorWidgetViewPr
const { columnCount = 5, ...rest } = props;
const [ isVisible, setIsVisible ] = useState(false);
const [ currentBadgeCode, setCurrentBadgeCode ] = useState<string>(null);
const { currentOffer = null, setPurchaseOptions = null } = useCatalog();
const { currentOffer = null } = useCatalogData();
const { setPurchaseOptions = null } = useCatalogUiState();
const { badgeCodes = [], activate = null, deactivate = null } = useInventoryBadges();
const previewStuffData = useMemo(() =>
@@ -1,6 +1,6 @@
import { FC, useEffect, useRef } from 'react';
import { AutoGrid, AutoGridProps, LayoutGridItem } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData } from '../../../../../hooks';
interface CatalogBundleGridWidgetViewProps extends AutoGridProps
{
@@ -10,8 +10,8 @@ interface CatalogBundleGridWidgetViewProps extends AutoGridProps
export const CatalogBundleGridWidgetView: FC<CatalogBundleGridWidgetViewProps> = props =>
{
const { columnCount = 5, children = null, ...rest } = props;
const { currentOffer = null } = useCatalog();
const elementRef = useRef<HTMLDivElement>();
const { currentOffer = null } = useCatalogData();
const elementRef = useRef<HTMLDivElement>(null);
useEffect(() =>
{
@@ -1,9 +1,10 @@
import { FC, useEffect } from 'react';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData, useCatalogUiState } from '../../../../../hooks';
export const CatalogFirstProductSelectorWidgetView: FC<{}> = props =>
{
const { currentPage = null, setCurrentOffer = null } = useCatalog();
const { currentPage = null } = useCatalogData();
const { setCurrentOffer = null } = useCatalogUiState();
useEffect(() =>
{
@@ -1,7 +1,7 @@
import { StringDataType } from '@nitrots/nitro-renderer';
import { FC, useMemo } from 'react';
import { BaseProps, LayoutBadgeImageView } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData, useCatalogUiState } from '../../../../../hooks';
interface CatalogGuildBadgeWidgetViewProps extends BaseProps<HTMLDivElement>
{
@@ -11,7 +11,8 @@ interface CatalogGuildBadgeWidgetViewProps extends BaseProps<HTMLDivElement>
export const CatalogGuildBadgeWidgetView: FC<CatalogGuildBadgeWidgetViewProps> = props =>
{
const { ...rest } = props;
const { currentOffer = null, purchaseOptions = null } = useCatalog();
const { currentOffer = null } = useCatalogData();
const { purchaseOptions = null } = useCatalogUiState();
const { previewStuffData = null } = purchaseOptions;
const badgeCode = useMemo(() =>
@@ -1,14 +1,15 @@
import { CatalogGroupsComposer, StringDataType } from '@nitrots/nitro-renderer';
import { StringDataType } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useState } from 'react';
import { LocalizeText, SendMessageComposer } from '../../../../../api';
import { LocalizeText } from '../../../../../api';
import { Button, Flex } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData, useCatalogUiState, useUserGroups } from '../../../../../hooks';
export const CatalogGuildSelectorWidgetView: FC<{}> = props =>
{
const [ selectedGroupIndex, setSelectedGroupIndex ] = useState<number>(0);
const { currentOffer = null, catalogOptions = null, setPurchaseOptions = null } = useCatalog();
const { groups = null } = catalogOptions;
const { currentOffer = null } = useCatalogData();
const { setPurchaseOptions = null } = useCatalogUiState();
const { data: groups = null } = useUserGroups();
const previewStuffData = useMemo(() =>
{
@@ -41,11 +42,6 @@ export const CatalogGuildSelectorWidgetView: FC<{}> = props =>
});
}, [ currentOffer, previewStuffData, setPurchaseOptions ]);
useEffect(() =>
{
SendMessageComposer(new CatalogGroupsComposer());
}, []);
if(!groups || !groups.length)
{
return (
@@ -1,7 +1,7 @@
import { FC, useCallback, useEffect, useRef, useState } from 'react';
import { IPurchasableOffer } from '../../../../../api';
import { AutoGrid, AutoGridProps } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogActions, useCatalogData } from '../../../../../hooks';
import { useCatalogAdmin } from '../../../CatalogAdminContext';
import { CatalogGridOfferView } from '../common/CatalogGridOfferView';
@@ -13,10 +13,11 @@ interface CatalogItemGridWidgetViewProps extends AutoGridProps
export const CatalogItemGridWidgetView: FC<CatalogItemGridWidgetViewProps> = props =>
{
const { columnCount = 5, children = null, ...rest } = props;
const { currentOffer = null, currentPage = null, selectCatalogOffer = null } = useCatalog();
const { currentOffer = null, currentPage = null } = useCatalogData();
const { selectCatalogOffer = null } = useCatalogActions();
const catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
const elementRef = useRef<HTMLDivElement>();
const elementRef = useRef<HTMLDivElement>(null);
const [ dragIndex, setDragIndex ] = useState<number | null>(null);
const [ dropIndex, setDropIndex ] = useState<number | null>(null);
@@ -25,13 +26,13 @@ export const CatalogItemGridWidgetView: FC<CatalogItemGridWidgetViewProps> = pro
if(elementRef && elementRef.current) elementRef.current.scrollTop = 0;
}, [ currentPage ]);
if(!currentPage) return null;
const selectOffer = (offer: IPurchasableOffer) =>
{
selectCatalogOffer(offer);
};
// 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);
@@ -67,6 +68,13 @@ export const CatalogItemGridWidgetView: FC<CatalogItemGridWidgetViewProps> = pro
setDropIndex(null);
}, []);
if(!currentPage) return null;
const selectOffer = (offer: IPurchasableOffer) =>
{
selectCatalogOffer(offer);
};
return (
<AutoGrid columnCount={ columnCount } innerRef={ elementRef } { ...rest }>
{ currentPage.offers && (currentPage.offers.length > 0) && currentPage.offers.map((offer, index) =>
@@ -1,11 +1,11 @@
import { FC } from 'react';
import { Offer } from '../../../../../api';
import { LayoutLimitedEditionCompletePlateView } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData } from '../../../../../hooks';
export const CatalogLimitedItemWidgetView: FC = props =>
{
const { currentOffer = null } = useCatalog();
const { currentOffer = null } = useCatalogData();
if(!currentOffer || (currentOffer.pricingModel !== Offer.PRICING_MODEL_SINGLE) || !currentOffer.product.isUniqueLimitedItem) return null;
@@ -2,7 +2,7 @@ import { FC } from 'react';
import { FaPlus } from 'react-icons/fa';
import { IPurchasableOffer } from '../../../../../api';
import { LayoutCurrencyIcon, Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogUiState } from '../../../../../hooks';
interface CatalogPriceDisplayWidgetViewProps
{
@@ -13,7 +13,7 @@ interface CatalogPriceDisplayWidgetViewProps
export const CatalogPriceDisplayWidgetView: FC<CatalogPriceDisplayWidgetViewProps> = props =>
{
const { offer = null, separator = false } = props;
const { purchaseOptions = null } = useCatalog();
const { purchaseOptions = null } = useCatalogUiState();
const { quantity = 1 } = purchaseOptions;
if(!offer) return null;
@@ -3,7 +3,7 @@ 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 { useCatalog, useLocalStorage, useNotification, usePurse, useUiEvent } from '../../../../../hooks';
import { useCatalogActions, useCatalogData, useCatalogUiState, useLocalStorage, useNotification, usePurse, useUiEvent } from '../../../../../hooks';
interface CatalogPurchaseWidgetViewProps
{
@@ -20,7 +20,9 @@ export const CatalogPurchaseWidgetView: FC<CatalogPurchaseWidgetViewProps> = pro
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, currentType = CatalogType.NORMAL, purchaseOptions = null, setPurchaseOptions = null, requestOfferToMover = null, setCatalogPlaceMultipleObjects = null, getBuilderFurniPlaceableStatus = null } = useCatalog();
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();
@@ -89,7 +91,10 @@ export const CatalogPurchaseWidgetView: FC<CatalogPurchaseWidgetViewProps> = pro
isPurchasingCatalogItem = true;
setPurchaseState(CatalogPurchaseState.PURCHASE);
setTimeout(() => { isPurchasingCatalogItem = false; }, 10000);
setTimeout(() =>
{
isPurchasingCatalogItem = false;
}, 10000);
if(purchaseCallback)
{
@@ -100,12 +105,11 @@ export const CatalogPurchaseWidgetView: FC<CatalogPurchaseWidgetViewProps> = pro
let pageId = currentOffer.page.pageId;
// if(pageId === -1)
// {
// const nodes = getNodesByOfferId(currentOffer.offerId);
// if(nodes) pageId = nodes[0].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));
};
@@ -132,15 +136,19 @@ export const CatalogPurchaseWidgetView: FC<CatalogPurchaseWidgetViewProps> = pro
};
}, [ purchaseState ]);
if(!currentOffer) return null;
// 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) return BuilderFurniPlaceableStatus.OKAY;
if(!isBuildersClubPlaceable || !getBuilderFurniPlaceableStatus || !currentOffer) return BuilderFurniPlaceableStatus.OKAY;
return getBuilderFurniPlaceableStatus(currentOffer);
}, [ currentOffer, getBuilderFurniPlaceableStatus, isBuildersClubPlaceable, builderPlaceableRefreshTick ]);
@@ -159,6 +167,8 @@ export const CatalogPurchaseWidgetView: FC<CatalogPurchaseWidgetViewProps> = pro
return () => clearInterval(interval);
}, [ isBuildersClubPlaceable ]);
if(!currentOffer) return null;
const PurchaseButton = () =>
{
if(isBuildersClubPlaceable)
@@ -1,10 +1,10 @@
import { FC } from 'react';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData } from '../../../../../hooks';
import { CatalogPriceDisplayWidgetView } from './CatalogPriceDisplayWidgetView';
export const CatalogSimplePriceWidgetView: FC<{}> = props =>
{
const { currentOffer = null } = useCatalog();
const { currentOffer = null } = useCatalogData();
return (
<div className="flex items-center bg-muted p-1 rounded gap-1">
@@ -1,7 +1,7 @@
import { FC, useEffect, useRef, useState } from 'react';
import { IPurchasableOffer, LocalizeText, Offer, ProductTypeEnum } from '../../../../../api';
import { AutoGrid, AutoGridProps, Button } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData, useCatalogUiState } from '../../../../../hooks';
import { CatalogGridOfferView } from '../common/CatalogGridOfferView';
interface CatalogSpacesWidgetViewProps extends AutoGridProps
@@ -17,8 +17,9 @@ export const CatalogSpacesWidgetView: FC<CatalogSpacesWidgetViewProps> = props =
const [ groupedOffers, setGroupedOffers ] = useState<IPurchasableOffer[][]>(null);
const [ selectedGroupIndex, setSelectedGroupIndex ] = useState(-1);
const [ selectedOfferForGroup, setSelectedOfferForGroup ] = useState<IPurchasableOffer[]>(null);
const { currentPage = null, currentOffer = null, setCurrentOffer = null, setPurchaseOptions = null } = useCatalog();
const elementRef = useRef<HTMLDivElement>();
const { currentPage = null, currentOffer = null } = useCatalogData();
const { setCurrentOffer = null, setPurchaseOptions = null } = useCatalogUiState();
const elementRef = useRef<HTMLDivElement>(null);
const setSelectedOffer = (offer: IPurchasableOffer) =>
{
@@ -1,14 +1,15 @@
import { FC } from 'react';
import { FaMinus, FaPlus } from 'react-icons/fa';
import { LocalizeText } from '../../../../../api';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData, useCatalogUiState } from '../../../../../hooks';
const MIN_VALUE: number = 1;
const MAX_VALUE: number = 99;
export const CatalogSpinnerWidgetView: FC<{}> = props =>
{
const { currentOffer = null, purchaseOptions = null, setPurchaseOptions = null } = useCatalog();
const { currentOffer = null } = useCatalogData();
const { purchaseOptions = null, setPurchaseOptions = null } = useCatalogUiState();
const { quantity = 1 } = purchaseOptions;
const updateQuantity = (value: number) =>
@@ -1,6 +1,6 @@
import { FC } from 'react';
import { Column, ColumnProps } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData } from '../../../../../hooks';
import { CatalogPriceDisplayWidgetView } from './CatalogPriceDisplayWidgetView';
interface CatalogSimplePriceWidgetViewProps extends ColumnProps
@@ -10,7 +10,7 @@ interface CatalogSimplePriceWidgetViewProps extends ColumnProps
export const CatalogTotalPriceWidget: FC<CatalogSimplePriceWidgetViewProps> = props =>
{
const { gap = 1, ...rest } = props;
const { currentOffer = null } = useCatalog();
const { currentOffer = null } = useCatalogData();
return (
<Column gap={ gap } { ...rest }>
@@ -2,11 +2,12 @@ import { GetAvatarRenderManager, GetSessionDataManager, Vector3d } from '@nitrot
import { FC, useEffect } from 'react';
import { BuildPurchasableClothingFigure, FurniCategory, Offer, ProductTypeEnum } from '../../../../../api';
import { AutoGrid, Column, LayoutGridItem, LayoutRoomPreviewerView } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData, useCatalogUiState } from '../../../../../hooks';
export const CatalogViewProductWidgetView: FC<{}> = props =>
{
const { currentOffer = null, roomPreviewer = null, purchaseOptions = null } = useCatalog();
const { currentOffer = null, roomPreviewer = null } = useCatalogData();
const { purchaseOptions = null } = useCatalogUiState();
const { previewStuffData = null } = purchaseOptions;
useEffect(() =>
@@ -1,32 +1,28 @@
import { GetTargetedOfferComposer, TargetedOfferData, TargetedOfferEvent } from '@nitrots/nitro-renderer';
import { useEffect, useState } from 'react';
import { SendMessageComposer } from '../../../../api';
import { useMessageEvent } from '../../../../hooks';
import { useState } from 'react';
import { useNitroQuery } from '../../../../api/nitro-query';
import { OfferBubbleView } from './OfferBubbleView';
import { OfferWindowView } from './OfferWindowView';
export const OfferView = () =>
{
const [ offer, setOffer ] = useState<TargetedOfferData>(null);
const [ opened, setOpened ] = useState<boolean>(false);
useMessageEvent<TargetedOfferEvent>(TargetedOfferEvent, evt =>
{
let parser = evt.getParser();
if(!parser) return;
setOffer(parser.data);
const { data: offer } = useNitroQuery<TargetedOfferEvent, TargetedOfferData>({
key: [ 'nitro', 'catalog', 'targeted-offer' ],
request: () => new GetTargetedOfferComposer(),
parser: TargetedOfferEvent,
select: evt => evt.getParser()?.data ?? null,
staleTime: Infinity
});
useEffect(() =>
{
SendMessageComposer(new GetTargetedOfferComposer());
}, []);
const [ opened, setOpened ] = useState<boolean>(false);
if(!offer) return;
if(!offer) return null;
return <>
{ opened ? <OfferWindowView offer={ offer } setOpen={ setOpened } /> : <OfferBubbleView offer={ offer } setOpen={ setOpened } /> }
</>;
return (
<>
{ opened
? <OfferWindowView offer={ offer } setOpen={ setOpened } />
: <OfferBubbleView offer={ offer } setOpen={ setOpened } /> }
</>
);
};