feat(catalog): catalogo modern Hippiehotel in albero separato (catalog-modern)

This commit is contained in:
medievalshell
2026-06-07 23:00:06 +02:00
parent 3cd6c5a518
commit 21d3a62b7a
73 changed files with 6931 additions and 4 deletions
@@ -0,0 +1,304 @@
import { CatalogAdminCreateOfferComposer, CatalogAdminCreatePageComposer, CatalogAdminDeleteOfferComposer, CatalogAdminDeletePageComposer, CatalogAdminMoveOfferComposer, CatalogAdminMovePageComposer, CatalogAdminPublishComposer, CatalogAdminResultEvent, CatalogAdminSaveOfferComposer, CatalogAdminSavePageComposer } from '@nitrots/nitro-renderer';
import { createContext, FC, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { ICatalogNode, IPurchasableOffer, NotificationAlertType, SendMessageComposer } from '../../api';
import { useCatalogUiState, useMessageEvent, useNotification } from '../../hooks';
export interface IPageEditData
{
pageId?: number;
caption: string;
captionSave: string;
parentId: number;
catalogMode: string;
pageLayout: string;
iconImage: number;
enabled: string;
visible: string;
minRank: number;
clubOnly?: string;
orderNum: number;
pageHeadline?: string;
pageTeaser?: string;
pageSpecial?: string;
pageText1?: string;
pageText2?: string;
pageTextDetails?: string;
pageTextTeaser?: string;
}
export interface IOfferEditData
{
offerId?: number;
pageId: number;
itemIds: string;
catalogName: string;
costCredits: number;
costPoints: number;
pointsType: number;
amount: number;
clubOnly: string;
extradata: string;
haveOffer: string;
offerId_group: number;
limitedStack: number;
orderNumber: number;
}
interface ICatalogAdminContext
{
adminMode: boolean;
setAdminMode: (value: boolean) => void;
editingOffer: IPurchasableOffer | null;
setEditingOffer: (offer: IPurchasableOffer | null) => void;
editingPageData: boolean;
setEditingPageData: (value: boolean) => void;
editingRootPage: boolean;
setEditingRootPage: (value: boolean) => void;
editingPageNode: ICatalogNode | null;
setEditingPageNode: (node: ICatalogNode | null) => void;
loading: boolean;
lastError: string | null;
savePage: (data: IPageEditData) => void;
createPage: (data: IPageEditData) => void;
deletePage: (pageId: number) => void;
saveOffer: (data: IOfferEditData) => void;
createOffer: (data: IOfferEditData) => void;
deleteOffer: (offerId: number) => void;
reorderOffers: (orders: { id: number; orderNumber: number }[]) => void;
reorderPage: (pageId: number, newParentId: number, newIndex: number) => void;
togglePageEnabled: (pageId: number) => void;
togglePageVisible: (pageId: number) => void;
publishCatalog: () => void;
hasPendingChanges: boolean;
}
const CatalogAdminContext = createContext<ICatalogAdminContext>(null);
export const useCatalogAdmin = () => useContext(CatalogAdminContext);
export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children }) =>
{
const { currentType } = useCatalogUiState();
const [ adminMode, setAdminMode ] = useState(false);
const [ editingOffer, setEditingOffer ] = useState<IPurchasableOffer | null>(null);
const [ editingPageData, setEditingPageData ] = useState(false);
const [ editingRootPage, setEditingRootPage ] = useState(false);
const [ editingPageNode, setEditingPageNode ] = useState<ICatalogNode | null>(null);
const [ loading, setLoading ] = useState(false);
const [ lastError, setLastError ] = useState<string | null>(null);
const [ hasPendingChanges, setHasPendingChanges ] = useState(false);
const pendingActionRef = useRef<string | null>(null);
const { simpleAlert = null } = useNotification();
useEffect(() =>
{
if(!adminMode) return;
const handleKeyDown = (e: KeyboardEvent) =>
{
if(e.key === 'Escape')
{
if(editingOffer)
{
setEditingOffer(null); e.preventDefault(); return;
}
if(editingPageData || editingRootPage || editingPageNode)
{
setEditingPageData(false);
setEditingRootPage(false);
setEditingPageNode(null);
e.preventDefault();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [ adminMode, editingOffer, editingPageData, editingRootPage, editingPageNode ]);
useMessageEvent(CatalogAdminResultEvent, (event: CatalogAdminResultEvent) =>
{
const parser = event.getParser();
const action = pendingActionRef.current;
pendingActionRef.current = null;
setLoading(false);
if(!parser.success)
{
setLastError(parser.message || 'Operation failed');
if(simpleAlert)
{
simpleAlert(parser.message || 'Operation failed', NotificationAlertType.ALERT, null, null, 'Admin Error');
}
}
else
{
setLastError(null);
setEditingOffer(null);
setEditingPageData(false);
setEditingRootPage(false);
setEditingPageNode(null);
if(action === 'publish')
{
setHasPendingChanges(false);
}
else
{
setHasPendingChanges(true);
}
if(simpleAlert && action)
{
const messages: Record<string, string> = {
'savePage': 'Page saved (publish to apply)',
'createPage': 'Page created (publish to apply)',
'deletePage': 'Page deleted (publish to apply)',
'saveOffer': 'Offer saved (publish to apply)',
'createOffer': 'Offer created (publish to apply)',
'deleteOffer': 'Offer deleted (publish to apply)',
'reorder': 'Order updated (publish to apply)',
'toggleEnabled': 'Page toggled (publish to apply)',
'toggleVisible': 'Visibility toggled (publish to apply)',
'movePage': 'Page moved (publish to apply)',
'publish': 'Catalog published! All users updated.',
};
simpleAlert(messages[action] || 'Operation completed', NotificationAlertType.DEFAULT, null, null, 'Catalog Admin');
}
}
});
const savePage = useCallback((data: IPageEditData) =>
{
setLoading(true);
setLastError(null);
pendingActionRef.current = 'savePage';
SendMessageComposer(new CatalogAdminSavePageComposer(
data.pageId || 0, data.caption, data.captionSave, data.pageLayout, data.iconImage,
data.minRank, data.visible === '1', data.enabled === '1',
data.orderNum, data.parentId,
data.pageHeadline || '', data.pageTeaser || '', data.pageTextDetails || '', currentType, data.catalogMode,
data.pageText1 || ''
));
}, [ currentType ]);
const createPage = useCallback((data: IPageEditData) =>
{
setLoading(true);
setLastError(null);
pendingActionRef.current = 'createPage';
SendMessageComposer(new CatalogAdminCreatePageComposer(
data.caption, data.captionSave, data.pageLayout, data.iconImage,
data.minRank, data.visible === '1', data.enabled === '1',
data.orderNum, data.parentId, currentType, data.catalogMode
));
}, [ currentType ]);
const deletePage = useCallback((pageId: number) =>
{
setLoading(true);
setLastError(null);
pendingActionRef.current = 'deletePage';
SendMessageComposer(new CatalogAdminDeletePageComposer(pageId, currentType));
}, [ currentType ]);
const saveOffer = useCallback((data: IOfferEditData) =>
{
setLoading(true);
setLastError(null);
pendingActionRef.current = 'saveOffer';
SendMessageComposer(new CatalogAdminSaveOfferComposer(
data.offerId || 0, data.pageId, data.itemIds || '',
data.catalogName, data.costCredits, data.costPoints, data.pointsType,
data.amount, data.clubOnly === '1' ? 1 : 0, data.extradata,
data.haveOffer === '1', data.offerId_group, data.limitedStack, data.orderNumber, currentType
));
}, [ currentType ]);
const createOffer = useCallback((data: IOfferEditData) =>
{
setLoading(true);
setLastError(null);
pendingActionRef.current = 'createOffer';
SendMessageComposer(new CatalogAdminCreateOfferComposer(
data.pageId, data.itemIds || '',
data.catalogName, data.costCredits, data.costPoints, data.pointsType,
data.amount, data.clubOnly === '1' ? 1 : 0, data.extradata,
data.haveOffer === '1', data.offerId_group, data.limitedStack, data.orderNumber, currentType
));
}, [ currentType ]);
const deleteOffer = useCallback((offerId: number) =>
{
setLoading(true);
setLastError(null);
pendingActionRef.current = 'deleteOffer';
SendMessageComposer(new CatalogAdminDeleteOfferComposer(offerId, currentType));
}, [ currentType ]);
const reorderOffers = useCallback((orders: { id: number; orderNumber: number }[]) =>
{
setLoading(true);
setLastError(null);
pendingActionRef.current = 'reorder';
for(const order of orders)
{
SendMessageComposer(new CatalogAdminMoveOfferComposer(order.id, order.orderNumber, currentType));
}
}, [ currentType ]);
const reorderPage = useCallback((pageId: number, newParentId: number, newIndex: number) =>
{
setLoading(true);
setLastError(null);
pendingActionRef.current = 'movePage';
SendMessageComposer(new CatalogAdminMovePageComposer(pageId, newParentId, newIndex, currentType));
}, [ currentType ]);
const togglePageEnabled = useCallback((pageId: number) =>
{
setLoading(true);
setLastError(null);
pendingActionRef.current = 'toggleEnabled';
SendMessageComposer(new CatalogAdminMovePageComposer(pageId, -1, -1, currentType));
}, [ currentType ]);
const togglePageVisible = useCallback((pageId: number) =>
{
setLoading(true);
setLastError(null);
pendingActionRef.current = 'toggleVisible';
SendMessageComposer(new CatalogAdminMovePageComposer(pageId, -2, -1, currentType));
}, [ currentType ]);
const publishCatalog = useCallback(() =>
{
setLoading(true);
setLastError(null);
pendingActionRef.current = 'publish';
SendMessageComposer(new CatalogAdminPublishComposer());
}, []);
return (
<CatalogAdminContext value={ {
adminMode, setAdminMode,
editingOffer, setEditingOffer,
editingPageData, setEditingPageData,
editingRootPage, setEditingRootPage,
editingPageNode, setEditingPageNode,
loading, lastError, hasPendingChanges,
savePage, createPage, deletePage,
saveOffer, createOffer, deleteOffer,
reorderOffers, reorderPage, togglePageEnabled, togglePageVisible,
publishCatalog
} }>
{ children }
</CatalogAdminContext>
);
};
@@ -0,0 +1,335 @@
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { FaCog, FaEdit, FaEye, FaEyeSlash, FaHeart, FaPlus, FaStar, FaTrash } from 'react-icons/fa';
import { CatalogType, LocalizeText } from '../../api';
import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common';
import { useCatalogActions, useCatalogData, useCatalogFavorites, useCatalogUiState, useHasPermission } from '../../hooks';
import { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext';
import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView';
import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView';
import { CatalogBuildersClubStatusView } from './views/catalog-header/CatalogBuildersClubStatusView';
import { CatalogIconView } from './views/catalog-icon/CatalogIconView';
import { CatalogFavoritesView } from './views/favorites/CatalogFavoritesView';
import { CatalogGiftView } from './views/gift/CatalogGiftView';
import { CatalogNavigationView } from './views/navigation/CatalogNavigationView';
import { CatalogSearchView } from './views/page/common/CatalogSearchView';
import { GetCatalogLayout } from './views/page/layout/GetCatalogLayout';
import { MarketplacePostOfferView } from './views/page/layout/marketplace/MarketplacePostOfferView';
const CatalogModernViewInner: FC<{}> = () =>
{
const { rootNode = null, currentPage = null, searchResult = null } = useCatalogData();
const { isVisible = false, setIsVisible = null, navigationHidden = false, setNavigationHidden = null, activeNodes = [], setSearchResult = null, currentType = CatalogType.NORMAL } = useCatalogUiState();
const { openPageByName = null, openPageByOfferId = null, activateNode = null, openCatalogByType = null, toggleCatalogByType = null } = useCatalogActions();
const catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
const setAdminMode = catalogAdmin?.setAdminMode ?? (() =>
{});
const hasPendingChanges = catalogAdmin?.hasPendingChanges ?? false;
const publishCatalog = catalogAdmin?.publishCatalog ?? (() =>
{});
const loading = catalogAdmin?.loading ?? false;
const { favoriteOfferIds, favoritePageIds } = useCatalogFavorites();
const [ showFavorites, setShowFavorites ] = useState(false);
const isMod = useHasPermission('acc_catalogfurni');
const totalFavs = favoriteOfferIds.length + favoritePageIds.length;
const buildersClubHeaderStyle = (currentType === CatalogType.BUILDER)
? { borderColor: '#d79d2e', borderBottomColor: '#000', background: 'linear-gradient(180deg, #d89f2d 0%, #c68515 100%)' }
: undefined;
// Desktop = fixed 780x520. On mobile the window clamps below the viewport so
// it reads as a dialog (with margins) instead of filling the whole phone
// screen — applies to both the normal catalog and the Builders Club.
const catalogCardSize = 'w-[780px] h-[520px] max-w-[96vw] max-h-[72vh] sm:max-w-[100vw] sm:max-h-[92vh]';
useEffect(() =>
{
const getCatalogTypeFromLink = (type?: string) =>
{
switch((type || '').toLowerCase())
{
case 'bc':
case 'builder':
case 'buildersclub':
case 'builders_club':
return CatalogType.BUILDER;
default:
return CatalogType.NORMAL;
}
};
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) =>
{
const parts = url.split('/');
if(parts.length < 2) return;
switch(parts[1])
{
case 'show':
if(parts.length > 2)
{
openCatalogByType(getCatalogTypeFromLink(parts[2]));
return;
}
setIsVisible(true);
return;
case 'hide':
setIsVisible(false);
return;
case 'toggle':
if(parts.length > 2)
{
toggleCatalogByType(getCatalogTypeFromLink(parts[2]));
return;
}
setIsVisible(prevValue => !prevValue);
return;
case 'open':
if(parts.length > 2)
{
if(parts.length === 4)
{
switch(parts[2])
{
case 'offerId':
openPageByOfferId(parseInt(parts[3]));
return;
}
}
else
{
openPageByName(parts[2]);
}
}
else
{
setIsVisible(true);
}
return;
}
},
eventUrlPrefix: 'catalog/'
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, [ setIsVisible, openPageByOfferId, openPageByName, openCatalogByType, toggleCatalogByType ]);
return (
<>
{ isVisible &&
<NitroCardView className={ `nitro-catalog ${ catalogCardSize }` } uniqueKey="catalog">
<NitroCardHeaderView className={ currentType === CatalogType.BUILDER ? 'builders-club-card-header' : '' } headerText={ LocalizeText('catalog.title') } onCloseClick={ () => setIsVisible(false) } style={ buildersClubHeaderStyle } />
<NitroCardContentView classNames={ [ 'p-0!', 'overflow-hidden!' ] }>
{ /* Admin banner */ }
{ adminMode &&
<div className="flex items-center justify-between bg-warning text-dark text-[10px] font-bold px-3 py-0.5 uppercase tracking-wider" style={ { textShadow: '0 1px 0 rgba(255,255,255,0.3)' } }>
<span> Admin Mode</span>
<button
className={ `px-3 py-0.5 rounded text-[10px] font-bold uppercase cursor-pointer transition-all ${ hasPendingChanges ? 'bg-success text-white animate-pulse shadow-md' : 'bg-white/50 text-dark hover:bg-success hover:text-white' }` }
disabled={ loading }
onClick={ () => publishCatalog() }
>
{ loading ? '...' : hasPendingChanges ? '⬆ Publish' : '⬆ Publish' }
</button>
</div> }
<CatalogBuildersClubStatusView />
<div className="flex min-h-0 flex-1">
{ /* === LEFT SIDEBAR === */ }
<div className="group/rail flex flex-col w-[52px] sm:hover:w-[175px] min-w-[52px] bg-card-grid-item border-r-2 border-card-grid-item-border py-1.5 gap-px overflow-y-auto overflow-x-hidden transition-[width] duration-200 ease-in-out [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{ /* Favorites toggle */ }
<div
className={ `flex items-center gap-2 mx-1 px-1.5 py-1.5 rounded cursor-pointer transition-all duration-150 ${ showFavorites ? 'bg-primary text-white' : 'hover:bg-card-grid-item-active' }` }
onClick={ () => setShowFavorites(!showFavorites) }
>
<div className="w-7 h-6 flex items-center justify-center shrink-0 relative">
<FaHeart className={ `text-xs ${ showFavorites ? 'text-white' : totalFavs > 0 ? 'text-danger' : 'text-muted' }` } />
{ totalFavs > 0 &&
<span className="absolute -top-1 -right-1 min-w-[14px] h-[14px] bg-danger text-white text-[8px] font-bold rounded-full flex items-center justify-center px-0.5 leading-none">
{ totalFavs }
</span> }
</div>
<span className={ `text-[11px] font-bold whitespace-nowrap opacity-0 group-hover/rail:opacity-100 transition-opacity duration-200 ${ showFavorites ? 'text-white' : '' }` }>{ LocalizeText('catalog.favorites') }</span>
</div>
<div className="border-b border-card-grid-item-border mx-2 my-0.5" />
{ /* Admin: root page actions */ }
{ adminMode && rootNode &&
<div className="flex items-center gap-1 mx-1 px-1.5 py-1 opacity-0 group-hover/rail:opacity-100 transition-opacity">
<button
className="flex items-center gap-1 text-[9px] text-success hover:text-green-800 cursor-pointer transition-colors"
title={ LocalizeText('catalog.admin.new.root.category') }
onClick={ () => catalogAdmin.createPage({ caption: 'New Category', captionSave: 'New Category', catalogMode: currentType, pageLayout: 'default_3x3', iconImage: 0, minRank: 1, visible: '1', enabled: '1', orderNum: 99, parentId: rootNode.pageId }) }
>
<FaPlus className="text-[8px]" />
<span className="whitespace-nowrap">{ LocalizeText('catalog.admin.new') }</span>
</button>
<button
className="flex items-center gap-1 text-[9px] text-primary hover:text-dark cursor-pointer transition-colors"
title={ LocalizeText('catalog.admin.edit.root') }
onClick={ () =>
{
catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(true); catalogAdmin.setEditingPageData(true);
} }
>
<FaEdit className="text-[8px]" />
<span className="whitespace-nowrap">{ LocalizeText('catalog.admin.root') }</span>
</button>
</div> }
{ /* Category icons */ }
{ rootNode && rootNode.children.length > 0 && rootNode.children.map((child, index) =>
{
if(!adminMode && !child.isVisible) return null;
const isHidden = !child.isVisible;
return (
<div
key={ `${ child.pageId }-${ index }` }
className={ `group/cat flex items-center gap-2 mx-1 px-1.5 py-1 rounded cursor-pointer transition-all duration-150 ${ isHidden ? 'opacity-40' : '' } ${ child.isActive ? 'bg-card-grid-item-active border border-card-grid-item-border-active shadow-inner1px' : 'border border-transparent hover:bg-card-grid-item-active' }` }
title={ adminMode ? `${ child.localization } [ID: ${ child.pageId }]${ isHidden ? ` (${ LocalizeText('catalog.admin.hidden') })` : '' }` : child.localization }
onClick={ () =>
{
if(searchResult) setSearchResult(null);
if(showFavorites) setShowFavorites(false);
activateNode(child);
} }
>
<div className="w-7 h-6 flex items-center justify-center shrink-0 relative">
<CatalogIconView icon={ child.iconId } />
{ isHidden && <FaEyeSlash className="absolute -bottom-0.5 -right-0.5 text-[7px] text-danger" /> }
</div>
<span className={ `text-[11px] whitespace-nowrap overflow-hidden truncate opacity-0 group-hover/rail:opacity-100 transition-opacity duration-200 flex-1 ${ child.isActive ? 'font-bold text-dark' : 'text-gray-700' }` }>
{ child.localization }
</span>
{ /* Admin actions on each root category */ }
{ adminMode &&
<div className="flex items-center gap-1 opacity-0 group-hover/rail:opacity-100 transition-opacity shrink-0">
<div
className="w-[18px] h-[18px] rounded flex items-center justify-center hover:bg-primary/20 cursor-pointer transition-colors"
title={ `${ LocalizeText('catalog.admin.edit.title') } "${ child.localization }"` }
onClick={ e =>
{
e.stopPropagation();
catalogAdmin.setEditingPageNode(child);
catalogAdmin.setEditingRootPage(false);
catalogAdmin.setEditingPageData(true);
} }
>
<FaEdit className="text-[9px] text-primary" />
</div>
<div
className="w-[18px] h-[18px] rounded flex items-center justify-center hover:bg-warning/20 cursor-pointer transition-colors"
title={ isHidden ? LocalizeText('catalog.admin.show') : LocalizeText('catalog.admin.hide') }
onClick={ e =>
{
e.stopPropagation();
catalogAdmin.togglePageVisible(child.pageId);
} }
>
{ isHidden
? <FaEye className="text-[9px] text-success" />
: <FaEyeSlash className="text-[9px] text-muted" /> }
</div>
<div
className="w-[18px] h-[18px] rounded flex items-center justify-center hover:bg-danger/20 cursor-pointer transition-colors"
title={ `${ LocalizeText('catalog.admin.delete.title') } "${ child.localization }"` }
onClick={ e =>
{
e.stopPropagation();
if(confirm(LocalizeText('catalog.admin.delete.category.confirm', [ 'name' ], [ child.localization ])))
{
catalogAdmin.deletePage(child.pageId);
}
} }
>
<FaTrash className="text-[9px] text-danger" />
</div>
</div> }
</div>
);
}) }
</div>
{ /* === MAIN AREA === */ }
<div className="flex flex-col flex-1 overflow-hidden bg-light">
{ /* Toolbar: search + admin */ }
<div className="flex items-center gap-2 px-2 py-1.5 bg-card-tab-item border-b border-card-grid-item-border">
{ /* Breadcrumb */ }
<div className="flex items-center gap-1 text-[11px] text-gray-600 min-w-0 flex-1">
<FaStar className="text-[9px] text-primary shrink-0" />
{ activeNodes && activeNodes.length > 0
? activeNodes.map((node, i) => (
<span key={ `${ node.pageId }-${ i }` } className="flex items-center gap-1 min-w-0">
{ i > 0 && <span className="text-[8px] opacity-30"></span> }
<span className={ `truncate ${ i === activeNodes.length - 1 ? 'font-bold text-dark' : 'cursor-pointer hover:text-primary' }` }
onClick={ i < activeNodes.length - 1 ? () => activateNode(node) : undefined }>
{ node.localization }
</span>
</span>
))
: <span className="text-muted">{ LocalizeText('catalog.title') }</span> }
</div>
<div className="w-[110px] sm:w-[180px] shrink-0">
<CatalogSearchView />
</div>
{ isMod &&
<button
className={ `flex items-center gap-1 px-2 py-1 rounded text-[10px] font-bold cursor-pointer transition-all border ${ adminMode ? 'bg-warning text-dark border-warning shadow-inner1px' : 'bg-card-grid-item text-gray-600 border-card-grid-item-border hover:bg-primary hover:text-white hover:border-primary' }` }
onClick={ () => setAdminMode(!adminMode) }
>
<FaCog className={ `${ adminMode ? 'animate-spin' : '' }` } style={ adminMode ? { animationDuration: '3s' } : {} } />
{ LocalizeText('catalog.admin') }
</button> }
</div>
{ /* Content area */ }
<div className="flex flex-1 overflow-hidden">
{ showFavorites
? <div className="flex-1 overflow-auto nitro-card-content-shell">
<CatalogFavoritesView onClose={ () => setShowFavorites(false) } />
</div>
: <>
{ !navigationHidden && activeNodes && activeNodes.length > 0 &&
<div className="w-[120px] min-w-[120px] sm:w-[170px] sm:min-w-[170px] border-r-2 border-card-grid-item-border bg-card-grid-item overflow-y-auto py-1">
<CatalogNavigationView node={ activeNodes[0] } />
</div> }
<div className="flex-1 overflow-auto p-2 nitro-card-content-shell">
{ adminMode && <CatalogAdminPageEditView /> }
{ GetCatalogLayout(currentPage, () => setNavigationHidden(true)) }
</div>
</> }
</div>
</div>
</div>
</NitroCardContentView>
</NitroCardView> }
<CatalogAdminOfferEditView />
<CatalogGiftView />
<MarketplacePostOfferView />
</>
);
};
export const CatalogModernView: FC<{}> = () =>
{
return (
<CatalogAdminProvider>
<CatalogModernViewInner />
</CatalogAdminProvider>
);
};
@@ -0,0 +1,10 @@
import { FC } from 'react';
export const CatalogPurchaseConfirmView: FC<{}> = props =>
{
const {} = props;
return (
<div></div>
);
};
@@ -0,0 +1,226 @@
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 { useCatalogData } from '../../../../hooks';
import { IOfferEditData, useCatalogAdmin } from '../../CatalogAdminContext';
export const CatalogAdminOfferEditView: FC<{}> = () =>
{
const { currentPage = null } = useCatalogData();
const catalogAdmin = useCatalogAdmin();
const editingOffer = catalogAdmin?.editingOffer ?? null;
const setEditingOffer = catalogAdmin?.setEditingOffer;
const saveOffer = catalogAdmin?.saveOffer;
const deleteOffer = catalogAdmin?.deleteOffer;
const createOffer = catalogAdmin?.createOffer;
const loading = catalogAdmin?.loading ?? false;
const [ itemIds, setItemIds ] = useState('');
const [ catalogName, setCatalogName ] = useState('');
const [ costCredits, setCostCredits ] = useState(0);
const [ costPoints, setCostPoints ] = useState(0);
const [ pointsType, setPointsType ] = useState(0);
const [ amount, setAmount ] = useState(1);
const [ clubOnly, setClubOnly ] = useState('0');
const [ extradata, setExtradata ] = useState('');
const [ haveOffer, setHaveOffer ] = useState('1');
const [ offerId, setOfferIdGroup ] = useState(-1);
const [ limitedStack, setLimitedStack ] = useState(0);
const [ orderNumber, setOrderNumber ] = useState(0);
const [ isNew, setIsNew ] = useState(false);
useEffect(() =>
{
if(!editingOffer) return;
if(editingOffer.offerId === -1)
{
setIsNew(true);
setItemIds('');
setCatalogName('');
setCostCredits(0);
setCostPoints(0);
setPointsType(0);
setAmount(1);
setClubOnly('0');
setExtradata('');
setHaveOffer('1');
setOfferIdGroup(-1);
setLimitedStack(0);
setOrderNumber(0);
}
else
{
setIsNew(false);
setItemIds(editingOffer.itemIds || '');
setCatalogName(editingOffer.localizationName || '');
setCostCredits(editingOffer.priceInCredits);
setCostPoints(editingOffer.priceInActivityPoints);
setPointsType(editingOffer.activityPointType);
setAmount(editingOffer.product?.productCount || 1);
setClubOnly(editingOffer.clubLevel > 0 ? '1' : '0');
setExtradata(editingOffer.product?.extraParam || '');
setHaveOffer(editingOffer.haveOffer ? '1' : '0');
setOfferIdGroup(editingOffer.offerId || -1);
setLimitedStack(0);
setOrderNumber(0);
}
}, [ editingOffer ]);
if(!editingOffer) return null;
const handleSave = async () =>
{
if(!saveOffer || !createOffer) return;
const data: IOfferEditData = {
offerId: isNew ? undefined : editingOffer.offerId,
pageId: currentPage?.pageId || 0,
itemIds,
catalogName,
costCredits,
costPoints,
pointsType,
amount,
clubOnly,
extradata,
haveOffer,
offerId_group: offerId,
limitedStack,
orderNumber
};
if(isNew) createOffer(data);
else saveOffer(data);
if(setEditingOffer) setEditingOffer(null);
};
const handleDelete = () =>
{
if(isNew || !deleteOffer || !confirm(LocalizeText('catalog.admin.delete.offer.confirm'))) return;
deleteOffer(editingOffer.offerId);
if(setEditingOffer) setEditingOffer(null);
};
const inputClass = 'text-[11px] border-2 border-card-grid-item-border rounded px-2 py-1 bg-white placeholder:text-[#4b5563] focus:outline-none focus:border-primary transition-colors';
return createPortal(
<div className="fixed inset-0 flex items-center justify-center" style={ { zIndex: 1000 } } onClick={ () => setEditingOffer(null) }>
<div className="absolute inset-0 bg-black/30 backdrop-blur-[1px]" />
<div className="nitro-card-shell relative w-[420px] overflow-hidden shadow-lg" onClick={ e => e.stopPropagation() }>
{ /* Header */ }
<div className="nitro-card-header-shell flex items-center justify-between px-3 py-2">
<span className="text-sm font-bold text-white">
{ isNew ? LocalizeText('catalog.admin.offer.new') : `${ LocalizeText('catalog.admin.offer.edit') } #${ editingOffer.offerId }` }
</span>
<div className="cursor-pointer" onClick={ () => setEditingOffer(null) }>
<FaTimes className="text-white/70 hover:text-white text-xs" />
</div>
</div>
<div className="p-3 flex flex-col gap-2.5">
{ /* Current name */ }
{ !isNew &&
<div className="text-[10px] text-muted bg-card-grid-item rounded px-2.5 py-1 font-mono border border-card-grid-item-border">
{ editingOffer.localizationName }
</div> }
{ /* Catalog Name */ }
<div className="flex flex-col gap-0.5">
<label className="text-[9px] text-primary uppercase font-bold">{ LocalizeText('catalog.admin.offer.name') }</label>
<input className={ inputClass } placeholder="es. rare_dragon_lamp" type="text" value={ catalogName } onChange={ e => setCatalogName(e.target.value) } />
</div>
{ /* Generale */ }
<div className="bg-white rounded border-2 border-card-grid-item-border p-2.5">
<div className="text-[9px] text-primary uppercase font-bold mb-1.5">{ LocalizeText('catalog.admin.offer.general') }</div>
<div className="grid grid-cols-3 gap-1.5">
<div className="flex flex-col gap-0.5">
<label className="text-[9px] text-muted">Item IDs</label>
<input className={ inputClass } placeholder="1234 or 100;200" type="text" value={ itemIds } onChange={ e => setItemIds(e.target.value) } />
</div>
<div className="flex flex-col gap-0.5">
<label className="text-[9px] text-muted">{ LocalizeText('catalog.admin.offer.quantity') }</label>
<input className={ inputClass } min={ 1 } type="number" value={ amount } onChange={ e => setAmount(parseInt(e.target.value) || 1) } />
</div>
<div className="flex flex-col gap-0.5">
<label className="text-[9px] text-muted">{ LocalizeText('catalog.admin.order') }</label>
<input className={ inputClass } min={ 0 } type="number" value={ orderNumber } onChange={ e => setOrderNumber(parseInt(e.target.value) || 0) } />
</div>
</div>
</div>
{ /* Prezzi */ }
<div className="bg-white rounded border-2 border-card-grid-item-border p-2.5">
<div className="text-[9px] text-primary uppercase font-bold mb-1.5">{ LocalizeText('catalog.admin.offer.prices') }</div>
<div className="grid grid-cols-3 gap-1.5">
<div className="flex flex-col gap-0.5">
<label className="text-[9px] text-muted">{ LocalizeText('catalog.admin.offer.credits') }</label>
<input className={ inputClass } min={ 0 } type="number" value={ costCredits } onChange={ e => setCostCredits(parseInt(e.target.value) || 0) } />
</div>
<div className="flex flex-col gap-0.5">
<label className="text-[9px] text-muted">{ LocalizeText('catalog.admin.offer.points') }</label>
<input className={ inputClass } min={ 0 } type="number" value={ costPoints } onChange={ e => setCostPoints(parseInt(e.target.value) || 0) } />
</div>
<div className="flex flex-col gap-0.5">
<label className="text-[9px] text-muted">{ LocalizeText('catalog.admin.offer.points.type') }</label>
<select className={ inputClass } value={ pointsType } onChange={ e => setPointsType(parseInt(e.target.value)) }>
<option value={ 0 }>Duckets</option>
<option value={ 5 }>Diamonds</option>
<option value={ 101 }>Seasonal</option>
</select>
</div>
</div>
</div>
{ /* Opzioni */ }
<div className="bg-white rounded border-2 border-card-grid-item-border p-2.5">
<div className="text-[9px] text-primary uppercase font-bold mb-1.5">{ LocalizeText('catalog.admin.offer.options') }</div>
<div className="grid grid-cols-3 gap-1.5 mb-1.5">
<div className="flex flex-col gap-0.5">
<label className="text-[9px] text-muted">{ LocalizeText('catalog.admin.offer.club.only') }</label>
<select className={ inputClass } value={ clubOnly } onChange={ e => setClubOnly(e.target.value) }>
<option value="0">No</option>
<option value="1">Si</option>
</select>
</div>
<div className="flex flex-col gap-0.5">
<label className="text-[9px] text-muted">Limited Stack</label>
<input className={ inputClass } min={ 0 } type="number" value={ limitedStack } onChange={ e => setLimitedStack(parseInt(e.target.value) || 0) } />
</div>
<div className="flex flex-col gap-0.5">
<label className="text-[9px] text-muted">Offer ID</label>
<input className={ inputClass } type="number" value={ offerId } onChange={ e => setOfferIdGroup(parseInt(e.target.value) || -1) } />
</div>
</div>
<div className="flex flex-col gap-0.5">
<label className="text-[9px] text-muted">{ LocalizeText('catalog.admin.offer.extradata') }</label>
<input className={ inputClass } placeholder={ LocalizeText('catalog.admin.offer.extradata') } type="text" value={ extradata } onChange={ e => setExtradata(e.target.value) } />
</div>
<div className="flex items-center gap-1.5 mt-1.5">
<input className="accent-primary" checked={ haveOffer === '1' } id="haveOffer" type="checkbox" onChange={ e => setHaveOffer(e.target.checked ? '1' : '0') } />
<label className="text-[10px] cursor-pointer" htmlFor="haveOffer">{ LocalizeText('catalog.admin.offer.have.offer') }</label>
</div>
</div>
{ /* Actions */ }
<div className="flex justify-between">
{ !isNew
? <button className="flex items-center gap-1 px-2 py-1 rounded text-[10px] font-bold bg-danger/10 text-danger border border-danger/30 hover:bg-danger/20 transition-colors cursor-pointer" onClick={ handleDelete }>
<FaTrash className="text-[8px]" /> { LocalizeText('catalog.admin.delete') }
</button>
: <div /> }
<button className="flex items-center gap-1 px-3 py-1 rounded text-[10px] font-bold bg-primary text-white hover:bg-secondary transition-colors cursor-pointer disabled:opacity-50" disabled={ loading } onClick={ handleSave }>
{ loading ? <FaSpinner className="text-[8px] animate-spin" /> : <FaSave className="text-[8px]" /> } { isNew ? LocalizeText('catalog.admin.create') : LocalizeText('catalog.admin.save') }
</button>
</div>
</div>
</div>
</div>,
document.body
);
};
@@ -0,0 +1,292 @@
import { FC, useEffect, useState } from 'react';
import { FaLanguage, FaSave, FaSpinner, FaTimes, FaTrash } from 'react-icons/fa';
import { CatalogType, LocalizeText } from '../../../../api';
import { useCatalogData, useCatalogUiState, useTranslationActions, useTranslationState } from '../../../../hooks';
import { IPageEditData, useCatalogAdmin } from '../../CatalogAdminContext';
const LAYOUT_OPTIONS = [
'default_3x3', 'frontpage4', 'pets', 'pets2', 'pets3',
'spaces_new', 'soundmachine', 'trophies', 'roomads',
'guild_frontpage', 'guild_forum', 'guild_custom_furni',
'vip_buy', 'builders_club_frontpage', 'builders_club_addons', 'builders_club_loyalty', 'marketplace', 'marketplace_own_items',
'recycler', 'recycler_info', 'recycler_prizes',
'info_loyalty', 'badge_display', 'bots', 'single_bundle',
'color_grouping', 'recent_purchases', 'custom_prefix'
];
const MODE_OPTIONS = [
{ value: 'NORMAL', label: 'Normal' },
{ value: 'BUILDER', label: 'Builder' },
{ value: 'BOTH', label: 'Both' }
];
export const CatalogAdminPageEditView: FC<{}> = () =>
{
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;
const editingPageNode = catalogAdmin?.editingPageNode ?? null;
const loading = catalogAdmin?.loading ?? false;
const [ caption, setCaption ] = useState('');
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);
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
? rootNode
: (activeNodes.length > 0 ? activeNodes[activeNodes.length - 1] : null);
const targetPageId = targetNode?.pageId ?? currentPage?.pageId;
const isRoot = editingRootPage;
const closeForm = () =>
{
catalogAdmin?.setEditingPageData(false);
catalogAdmin?.setEditingRootPage(false);
catalogAdmin?.setEditingPageNode(null);
};
useEffect(() =>
{
if(!editingPageData || !targetNode) return;
// The server appends " (pageId)" to the caption for mods/admins (see
// CatalogPagesListComposer). Strip that exact suffix before seeding the
// edit field, otherwise saving folds the id back into the stored
// caption and it multiplies on every edit ("Wired (1114) (1114) ...").
const rawCaption = (targetNode.localization || '').replace(new RegExp(`\\s*\\(${ targetNode.pageId }\\)\\s*$`), '');
setCaption(rawCaption);
setCaptionSave(targetNode.pageName || rawCaption);
setCatalogMode(currentType === CatalogType.BUILDER ? 'BUILDER' : 'NORMAL');
setPageLayout(currentPage?.layoutCode || 'default_3x3');
setIconImage(targetNode.iconId ?? 0);
setVisible(targetNode.isVisible ? '1' : '0');
setEnabled('1');
setMinRank(1);
setOrderNum(0);
const matchesLoadedPage = currentPage && targetPageId === currentPage.pageId;
const existingText1 = matchesLoadedPage && currentPage.localization
? currentPage.localization.getText(0)
: '';
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;
const inputClass = 'text-[11px] border-2 border-card-grid-item-border rounded px-2 py-1 bg-white focus:outline-none focus:border-primary transition-colors';
const handleSave = async () =>
{
if(!catalogAdmin?.savePage) return;
const data: IPageEditData = {
pageId: targetPageId,
caption,
captionSave,
catalogMode,
pageLayout,
iconImage,
minRank,
visible,
enabled,
orderNum,
parentId,
pageText1,
};
catalogAdmin.savePage(data);
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 () =>
{
if(!catalogAdmin?.deletePage || isRoot) return;
if(!confirm(LocalizeText('catalog.admin.delete.page.confirm', [ 'name' ], [ targetNode.localization ]))) return;
catalogAdmin.deletePage(targetPageId);
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 }` }
</span>
<FaTimes className="text-muted cursor-pointer hover:text-danger text-[10px]" onClick={ closeForm } />
</div>
<div className="grid grid-cols-3 gap-1.5">
<div className="flex flex-col gap-0.5 col-span-2">
<label className="text-[9px] text-muted uppercase font-bold">Caption</label>
<input className={ inputClass } value={ caption } onChange={ e => setCaption(e.target.value) } />
</div>
<div className="flex flex-col gap-0.5">
<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>
<select className={ inputClass } value={ catalogMode } onChange={ e => setCatalogMode(e.target.value) }>
{ MODE_OPTIONS.map(option => <option key={ option.value } value={ option.value }>{ option.label }</option>) }
</select>
</div>
<div className="flex flex-col gap-0.5">
<label className="text-[9px] text-muted uppercase font-bold">Layout</label>
<select className={ inputClass } value={ pageLayout } onChange={ e => setPageLayout(e.target.value) }>
{ LAYOUT_OPTIONS.map(l => <option key={ l } value={ l }>{ l }</option>) }
</select>
</div>
<div className="flex flex-col gap-0.5">
<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') } />
{ LocalizeText('catalog.admin.visible') }
</label>
<label className="flex items-center gap-1 text-[10px] cursor-pointer">
<input className="accent-primary" checked={ enabled === '1' } type="checkbox" onChange={ e => setEnabled(e.target.checked ? '1' : '0') } />
{ 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">
{ !isRoot
? <button className="flex items-center gap-1 px-2 py-1 rounded text-[10px] font-bold bg-danger/10 text-danger border border-danger/30 hover:bg-danger/20 transition-colors cursor-pointer" onClick={ handleDelete }>
<FaTrash className="text-[8px]" /> { LocalizeText('catalog.admin.delete') }
</button>
: <div /> }
<button className="flex items-center gap-1 px-3 py-1 rounded text-[10px] font-bold bg-primary text-white hover:bg-secondary transition-colors cursor-pointer disabled:opacity-50" disabled={ loading } onClick={ handleSave }>
{ loading ? <FaSpinner className="text-[8px] animate-spin" /> : <FaSave className="text-[8px]" /> } { LocalizeText('catalog.admin.save') }
</button>
</div>
</div>
);
};
@@ -0,0 +1,87 @@
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 { useCatalogData, useCatalogUiState } from '../../../../hooks';
export const CatalogBuildersClubStatusView: FC = () =>
{
const { furniCount = 0, furniLimit = 0, secondsLeft = 0, secondsLeftWithGrace = 0, updateTime = 0 } = useCatalogData();
const { currentType = CatalogType.NORMAL } = useCatalogUiState();
const [ ticker, setTicker ] = useState(() => GetTickerTime());
useEffect(() =>
{
if(currentType !== CatalogType.BUILDER) return;
const interval = window.setInterval(() => setTicker(GetTickerTime()), 1000);
return () => window.clearInterval(interval);
}, [ currentType ]);
const localizeOrDefault = (key: string, fallback: string, parameters: string[] = [], values: string[] = []) =>
{
const localized = LocalizeText(key, parameters, values);
return ((localized && (localized !== key)) ? localized : fallback);
};
const remainingSeconds = useMemo(() =>
{
const baseSeconds = (secondsLeft > 0) ? secondsLeft : secondsLeftWithGrace;
if(baseSeconds <= 0) return 0;
const elapsed = ((updateTime > 0) ? Math.floor((ticker - updateTime) / 1000) : 0);
return Math.max(0, (baseSeconds - elapsed));
}, [ secondsLeft, secondsLeftWithGrace, ticker, updateTime ]);
const isFullMember = (secondsLeft > 0);
const membershipStatus = localizeOrDefault(
isFullMember ? 'builder.header.status.member' : 'builder.header.status.trial',
isFullMember ? 'Membro Completo' : 'Prova Gratuita'
);
const title = localizeOrDefault(
'builder.header.title',
`Stato Builders' Club: ${ membershipStatus }`,
[ 'BCSTATUS' ],
[ membershipStatus ]
);
const durationText = localizeOrDefault(
'builder.header.status.membership',
`Tempo mancante: ${ FriendlyTime.format(remainingSeconds) }`,
[ 'DURATION' ],
[ FriendlyTime.format(remainingSeconds) ]
);
const limitText = localizeOrDefault(
'builder.header.status.limit',
`Furni usati: ${ furniCount }/${ furniLimit }`,
[ 'COUNT', 'LIMIT' ],
[ furniCount.toString(), furniLimit.toString() ]
);
if(currentType !== CatalogType.BUILDER) return null;
return (
<div className="builders-club-status-shell flex items-center gap-3 px-4 py-3">
<div className="builders-club-status-icon-shell flex h-[44px] w-[44px] shrink-0 items-center justify-center rounded-md">
<img alt="" className="h-[28px] w-[28px] object-contain" src={ buildersClubIcon } />
</div>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-[13px] leading-none font-bold text-white">
{ title }
</span>
<span className="mt-1 text-[11px] leading-tight text-white/95">
{ durationText }
</span>
<span className="text-[11px] leading-tight text-[#ffba45]">
{ limitText }
</span>
</div>
</div>
);
};
@@ -0,0 +1,20 @@
import { FC } from 'react';
import { GetConfigurationValue } from '../../../../api';
export interface CatalogHeaderViewProps
{
imageUrl?: string;
}
export const CatalogHeaderView: FC<CatalogHeaderViewProps> = props =>
{
const { imageUrl = null } = props;
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 }) =>
{
currentTarget.src = GetConfigurationValue<string>('catalog.asset.image.url').replace('%name%', 'catalog_header_roombuilder');
} } />
</div>;
};
@@ -0,0 +1,20 @@
import { FC, useMemo } from 'react';
import { GetConfigurationValue } from '../../../../api';
export interface CatalogIconViewProps
{
icon: number;
className?: string;
}
export const CatalogIconView: FC<CatalogIconViewProps> = props =>
{
const { icon = 0, className = '' } = props;
const iconUrl = useMemo(() =>
{
return ((GetConfigurationValue<string>('catalog.asset.icon.url')).replace('%name%', icon.toString()));
}, [ icon ]);
return <img src={ iconUrl } alt="" className={ `w-5 h-5 object-contain image-rendering-pixelated ${ className }` } draggable={ false } />;
};
@@ -0,0 +1,30 @@
import { FC } from 'react';
import { ICatalogNode } from '../../../../api';
import { CatalogIconView } from '../catalog-icon/CatalogIconView';
interface CatalogRailItemViewProps
{
node: ICatalogNode;
isActive: boolean;
onClick: () => void;
}
export const CatalogRailItemView: FC<CatalogRailItemViewProps> = props =>
{
const { node, isActive, onClick } = props;
return (
<div
className={ `flex items-center gap-2 px-1.5 py-1.5 rounded-lg cursor-pointer transition-all duration-150 shrink-0 ${ isActive ? 'bg-white shadow-catalog-card ring-1 ring-catalog-accent/30' : 'hover:bg-white/60' }` }
title={ node.localization }
onClick={ onClick }
>
<div className="w-8 h-8 flex items-center justify-center shrink-0">
<CatalogIconView icon={ node.iconId } className="w-6 h-6" />
</div>
<span className={ `text-[11px] font-medium whitespace-nowrap overflow-hidden opacity-0 group-hover:opacity-100 transition-opacity duration-200 truncate ${ isActive ? 'text-catalog-accent' : 'text-catalog-text' }` }>
{ node.localization }
</span>
</div>
);
};
@@ -0,0 +1,47 @@
import { GetEventDispatcher, NitroToolbarAnimateIconEvent, RoomPreviewer, TextureUtils, ToolbarIconEnum } from '@nitrots/nitro-renderer';
import { FC, useRef } from 'react';
import { LayoutRoomPreviewerView } from '../../../../common';
import { CatalogPurchasedEvent } from '../../../../events';
import { useUiEvent } from '../../../../hooks';
export const CatalogRoomPreviewerView: FC<{
roomPreviewer: RoomPreviewer;
height?: number;
}> = props =>
{
const { roomPreviewer = null } = props;
const elementRef = useRef<HTMLDivElement>(null);
useUiEvent(CatalogPurchasedEvent.PURCHASE_SUCCESS, event =>
{
if(!elementRef) return;
const renderTexture = roomPreviewer.getRoomObjectCurrentImage();
if(!renderTexture) return;
(async () =>
{
const image = await TextureUtils.generateImage(renderTexture);
if(!image) return;
const bounds = elementRef.current.getBoundingClientRect();
const x = (bounds.x + (bounds.width / 2));
const y = (bounds.y + (bounds.height / 2));
const animateEvent = new NitroToolbarAnimateIconEvent(image, x, y);
animateEvent.iconName = ToolbarIconEnum.INVENTORY;
GetEventDispatcher().dispatchEvent(animateEvent);
})();
});
return (
<div ref={ elementRef }>
<LayoutRoomPreviewerView { ...props } />
</div>
);
};
@@ -0,0 +1,167 @@
import { FC, useMemo } from 'react';
import { FaHeart, FaStar, FaTimes } from 'react-icons/fa';
import { ICatalogNode, LocalizeText } from '../../../../api';
import { useCatalogActions, useCatalogData, useCatalogFavorites } from '../../../../hooks';
import { CatalogIconView } from '../catalog-icon/CatalogIconView';
interface CatalogFavoritesViewProps
{
onClose: () => void;
}
export const CatalogFavoritesView: FC<CatalogFavoritesViewProps> = props =>
{
const { onClose } = props;
const { favoriteOffers, favoritePageIds, toggleFavoritePage, toggleFavoriteOffer } = useCatalogFavorites();
const { offersToNodes, rootNode } = useCatalogData();
const { activateNode, openPageByOfferId } = useCatalogActions();
const favoritePages = useMemo(() =>
{
if(!rootNode || favoritePageIds.length === 0) return [];
const pages: Array<{ pageId: number; name: string; iconId: number; node: ICatalogNode }> = [];
const findNode = (node: ICatalogNode) =>
{
if(favoritePageIds.includes(node.pageId))
{
pages.push({ pageId: node.pageId, name: node.localization, iconId: node.iconId, node });
}
if(node.children)
{
for(const child of node.children) findNode(child);
}
};
findNode(rootNode);
return pages;
}, [ favoritePageIds, rootNode ]);
// Enrich offers with node data if available
const enrichedOffers = useMemo(() =>
{
return favoriteOffers.map(fav =>
{
let nodeName: string | null = null;
let nodeIconId: number | null = null;
if(offersToNodes)
{
const nodes = offersToNodes.get(fav.offerId);
if(nodes && nodes.length > 0)
{
nodeName = nodes[0].localization;
nodeIconId = nodes[0].iconId;
}
}
return {
...fav,
displayName: fav.name || nodeName || `Offer #${ fav.offerId }`,
nodeIconId
};
});
}, [ favoriteOffers, offersToNodes ]);
return (
<div className="flex flex-col h-full gap-2 p-2.5">
{ /* Header */ }
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<FaHeart className="text-danger text-xs" />
<span className="text-sm font-bold">{ LocalizeText('catalog.favorites') }</span>
<span className="text-[10px] text-muted font-bold">({ enrichedOffers.length + favoritePages.length })</span>
</div>
<button className="text-muted hover:text-danger cursor-pointer transition-colors" onClick={ onClose }>
<FaTimes className="text-[10px]" />
</button>
</div>
<div className="flex-1 overflow-y-auto flex flex-col gap-2.5">
{ /* Favorite Pages */ }
{ favoritePages.length > 0 &&
<div>
<div className="flex items-center gap-1 mb-1">
<FaStar className="text-warning text-[8px]" />
<span className="text-[10px] font-bold text-muted uppercase tracking-wider">{ LocalizeText('catalog.favorites.pages') }</span>
</div>
<div className="flex flex-col gap-px">
{ favoritePages.map(page => (
<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();
} }
>
<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);
} }
/>
</div>
)) }
</div>
</div> }
{ /* Favorite Offers */ }
{ enrichedOffers.length > 0 &&
<div>
<div className="flex items-center gap-1 mb-1">
<FaHeart className="text-danger text-[8px]" />
<span className="text-[10px] font-bold text-muted uppercase tracking-wider">{ LocalizeText('catalog.favorites.furni') }</span>
</div>
<div className="flex flex-col gap-px">
{ enrichedOffers.map(fav => (
<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();
} }
>
{ /* 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">
{ fav.iconUrl
? <img className="max-w-full max-h-full object-contain image-rendering-pixelated" src={ fav.iconUrl } />
: fav.nodeIconId !== null
? <CatalogIconView icon={ fav.nodeIconId } />
: <FaHeart className="text-[9px] text-muted" />
}
</div>
<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);
} }
/>
</div>
)) }
</div>
</div> }
{ /* Empty state */ }
{ favoritePages.length === 0 && enrichedOffers.length === 0 &&
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-muted">
<FaHeart className="text-xl text-card-grid-item-border mx-auto mb-1.5" />
<p className="text-[11px] font-bold">{ LocalizeText('catalog.favorites.empty') }</p>
<p className="text-[10px] mt-0.5">{ LocalizeText('catalog.favorites.empty.hint') }</p>
</div>
</div> }
</div>
</div>
);
};
@@ -0,0 +1,307 @@
import { GetGiftWrappingConfigurationComposer, GetSessionDataManager, GiftReceiverNotFoundEvent, GiftWrappingConfigurationEvent, PurchaseFromCatalogAsGiftComposer } from '@nitrots/nitro-renderer';
import { ChangeEvent, FC, useCallback, useEffect, useMemo, useState } from 'react';
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
import { ColorUtils, GiftWrappingConfiguration, 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 { useFriends, useMessageEvent, useMessageEventState, useUiEvent } from '../../../../hooks';
import { classNames } from '../../../../layout';
let isBuyingGift = false;
export const CatalogGiftView: FC<{}> = props =>
{
const [ isVisible, setIsVisible ] = useState<boolean>(false);
const [ pageId, setPageId ] = useState<number>(0);
const [ offerId, setOfferId ] = useState<number>(0);
const [ extraData, setExtraData ] = useState<string>('');
const [ receiverName, setReceiverName ] = useState<string>('');
const [ showMyFace, setShowMyFace ] = useState<boolean>(true);
const [ message, setMessage ] = useState<string>('');
const [ selectedBoxIndex, setSelectedBoxIndex ] = useState<number>(0);
const [ selectedRibbonIndex, setSelectedRibbonIndex ] = useState<number>(0);
const [ selectedColorId, setSelectedColorId ] = useState<number>(0);
const [ receiverNotFound, setReceiverNotFound ] = useState<boolean>(false);
const { friends } = useFriends();
const giftConfiguration = useMessageEventState<GiftWrappingConfigurationEvent, GiftWrappingConfiguration | null>(
GiftWrappingConfigurationEvent,
event => new GiftWrappingConfiguration(event.getParser()),
null
);
const [ suggestions, setSuggestions ] = useState([]);
const [ isAutocompleteVisible, setIsAutocompleteVisible ] = useState(true);
const boxTypes = useMemo<number[]>(() =>
{
if(!giftConfiguration) return [];
const list = [ ...giftConfiguration.boxTypes ];
const defaults = giftConfiguration.defaultStuffTypes;
if(defaults && defaults.length)
{
const pickIndex = Math.floor(Math.random() * Math.max(defaults.length - 1, 1));
list.push(defaults[pickIndex]);
}
return list;
}, [ giftConfiguration ]);
const colors = useMemo<{ id: number, color: string }[]>(() =>
{
if(!giftConfiguration) return [];
const result: { id: number, color: string }[] = [];
for(const colorId of giftConfiguration.stuffTypes)
{
const giftData = GetSessionDataManager().getFloorItemData(colorId);
if(!giftData) continue;
if(giftData.colors && giftData.colors.length > 0) result.push({ id: colorId, color: ColorUtils.makeColorNumberHex(giftData.colors[0]) });
}
return result;
}, [ giftConfiguration ]);
const maxBoxIndex = Math.max(boxTypes.length - 1, 0);
const maxRibbonIndex = Math.max(boxTypes.length - 1, 0);
useEffect(() =>
{
if(!colors.length) return;
setSelectedColorId(prev => (prev || colors[0].id));
}, [ colors ]);
const onClose = useCallback(() =>
{
isBuyingGift = false;
setIsVisible(false);
setPageId(0);
setOfferId(0);
setExtraData('');
setReceiverName('');
setShowMyFace(true);
setMessage('');
setSelectedBoxIndex(0);
setSelectedRibbonIndex(0);
setIsAutocompleteVisible(false);
setSuggestions([]);
if(colors.length) setSelectedColorId(colors[0].id);
}, [ colors ]);
const isBoxDefault = useMemo(() =>
{
return giftConfiguration ? (giftConfiguration.defaultStuffTypes.findIndex(s => (s === boxTypes[selectedBoxIndex])) > -1) : false;
}, [ boxTypes, giftConfiguration, selectedBoxIndex ]);
const boxExtraData = useMemo(() =>
{
if(!giftConfiguration || !boxTypes.length) return '';
const boxType = boxTypes[selectedBoxIndex];
const ribbonType = giftConfiguration.ribbonTypes[selectedRibbonIndex];
if(boxType === undefined || ribbonType === undefined) return '';
return ((boxType * 1000) + ribbonType).toString();
}, [ giftConfiguration, selectedBoxIndex, selectedRibbonIndex, boxTypes ]);
const isColorable = useMemo(() =>
{
if(!giftConfiguration) return false;
if(isBoxDefault) return false;
const boxType = boxTypes[selectedBoxIndex];
return (boxType === 8 || (boxType >= 3 && boxType <= 6)) ? false : true;
}, [ giftConfiguration, selectedBoxIndex, isBoxDefault, boxTypes ]);
const colourId = useMemo(() =>
{
return isBoxDefault ? boxTypes[selectedBoxIndex] : selectedColorId;
}, [ isBoxDefault, boxTypes, selectedBoxIndex, selectedColorId ]);
const allFriends = friends.filter((friend: MessengerFriend) => friend.id !== -1);
const onTextChanged = (e: ChangeEvent<HTMLInputElement>) =>
{
const value = e.target.value;
let suggestions = [];
if(value.length > 0)
{
suggestions = allFriends.sort().filter((friend: MessengerFriend) => friend.name.includes(value));
}
setReceiverName(value);
setIsAutocompleteVisible(true);
setSuggestions(suggestions);
};
const selectedReceiverName = (friendName: string) =>
{
setReceiverName(friendName);
setIsAutocompleteVisible(false);
};
const handleAction = useCallback((action: string) =>
{
switch(action)
{
case 'prev_box':
setSelectedBoxIndex(value => (value === 0 ? maxBoxIndex : value - 1));
return;
case 'next_box':
setSelectedBoxIndex(value => (value === maxBoxIndex ? 0 : value + 1));
return;
case 'prev_ribbon':
setSelectedRibbonIndex(value => (value === 0 ? maxRibbonIndex : value - 1));
return;
case 'next_ribbon':
setSelectedRibbonIndex(value => (value === maxRibbonIndex ? 0 : value + 1));
return;
case 'buy':
if(!receiverName || (receiverName.length === 0))
{
setReceiverNotFound(true);
return;
}
if(isBuyingGift) return;
isBuyingGift = true;
setTimeout(() =>
{
isBuyingGift = false;
}, 10000);
SendMessageComposer(new PurchaseFromCatalogAsGiftComposer(pageId, offerId, extraData, receiverName, message, colourId, selectedBoxIndex, selectedRibbonIndex, showMyFace));
return;
}
}, [ colourId, extraData, maxBoxIndex, maxRibbonIndex, message, offerId, pageId, receiverName, selectedBoxIndex, selectedRibbonIndex, showMyFace ]);
useMessageEvent<GiftReceiverNotFoundEvent>(GiftReceiverNotFoundEvent, event => setReceiverNotFound(true));
useUiEvent([
CatalogPurchasedEvent.PURCHASE_SUCCESS,
CatalogEvent.INIT_GIFT ], event =>
{
switch(event.type)
{
case CatalogPurchasedEvent.PURCHASE_SUCCESS:
isBuyingGift = false;
onClose();
return;
case CatalogEvent.INIT_GIFT:
const castedEvent = (event as CatalogInitGiftEvent);
onClose();
setPageId(castedEvent.pageId);
setOfferId(castedEvent.offerId);
setExtraData(castedEvent.extraData);
setSelectedBoxIndex(0);
setSelectedRibbonIndex(0);
setIsVisible(true);
if(!giftConfiguration) SendMessageComposer(new GetGiftWrappingConfigurationComposer());
return;
}
});
useEffect(() =>
{
setReceiverNotFound(false);
}, [ receiverName ]);
if(!isVisible || !giftConfiguration || !giftConfiguration.isEnabled || !boxTypes.length) return null;
const boxName = 'catalog.gift_wrapping_new.box.' + (isBoxDefault ? 'default' : boxTypes[selectedBoxIndex]);
const ribbonName = `catalog.gift_wrapping_new.ribbon.${ selectedRibbonIndex }`;
const priceText = 'catalog.gift_wrapping_new.' + (isBoxDefault ? 'freeprice' : 'price');
return (
<NitroCardView className="nitro-catalog-gift" theme="primary-slim" uniqueKey="catalog-gift">
<NitroCardHeaderView headerText={ LocalizeText('catalog.gift_wrapping.title') } onCloseClick={ onClose } />
<NitroCardContentView className="text-black">
<FormGroup column>
<Text>{ LocalizeText('catalog.gift_wrapping.receiver') }</Text>
<input className={ classNames('form-control form-control-sm', receiverNotFound && 'is-invalid') } type="text" value={ receiverName } onChange={ (e) => onTextChanged(e) } />
{ (suggestions.length > 0 && isAutocompleteVisible) &&
<Column className="autocomplete-gift-container">
{ suggestions.map((friend: MessengerFriend) => (
<div key={ friend.id } className="autocomplete-gift-item" onClick={ (e) => selectedReceiverName(friend.name) }>{ friend.name }</div>
)) }
</Column>
}
{ receiverNotFound &&
<div className="invalid-feedback">{ LocalizeText('catalog.gift_wrapping.receiver_not_found.title') }</div> }
</FormGroup>
<LayoutGiftTagView editable={ true } figure={ GetSessionDataManager().figure } message={ message } userName={ GetSessionDataManager().userName } onChange={ (value) => setMessage(value) } />
<div className="form-check">
<input checked={ showMyFace } className="form-check-input" name="showMyFace" type="checkbox" onChange={ (e) => setShowMyFace(value => !value) } />
<label className="form-check-label">{ LocalizeText('catalog.gift_wrapping.show_face.title') }</label>
</div>
<div className="items-center gap-2">
<div className="gift-preview">
{ (colourId > 0 && boxExtraData) &&
<LayoutFurniImageView extraData={ boxExtraData } productClassId={ colourId } productType={ ProductTypeEnum.FLOOR } /> }
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<div className="inline-flex">
<Button variant="primary" onClick={ () => handleAction('prev_box') }>
<FaChevronLeft className="fa-icon" />
</Button>
<Button variant="primary" onClick={ () => handleAction('next_box') }>
<FaChevronRight className="fa-icon" />
</Button>
</div>
<div className="flex flex-col gap-1">
<Text fontWeight="bold">{ LocalizeText(boxName) }</Text>
<div className="flex items-center gap-1">
{ LocalizeText(priceText, [ 'price' ], [ giftConfiguration.price.toString() ]) }
<LayoutCurrencyIcon type={ -1 } />
</div>
</div>
</div>
<div className={ `flex items-center gap-2 ${ isColorable ? '' : 'opacity-50 pointer-events-none' }` }>
<div className="inline-flex">
<Button variant="primary" onClick={ () => handleAction('prev_ribbon') }>
<FaChevronLeft className="fa-icon" />
</Button>
<Button variant="primary" onClick={ () => handleAction('next_ribbon') }>
<FaChevronRight className="fa-icon" />
</Button>
</div>
<Text fontWeight="bold">{ LocalizeText(ribbonName) }</Text>
</div>
</div>
</div>
<Column className={ isColorable ? '' : 'opacity-50 pointer-events-none' } gap={ 1 }>
<Text fontWeight="bold">
{ LocalizeText('catalog.gift_wrapping.pick_color') }
</Text>
<div className="relative inline-flex align-middle w-full">
{ colors.map(color => <Button key={ color.id } active={ (color.id === selectedColorId) } disabled={ !isColorable } style={ { backgroundColor: color.color } } variant="dark" onClick={ () => setSelectedColorId(color.id) } />) }
</div>
</Column>
<div className="flex items-center justify-between">
<Button className="text-black" variant="link" onClick={ onClose }>
{ LocalizeText('cancel') }
</Button>
<Button variant="success" onClick={ () => handleAction('buy') }>
{ LocalizeText('catalog.gift_wrapping.give_gift') }
</Button>
</div>
</NitroCardContentView>
</NitroCardView>
);
};
@@ -0,0 +1,34 @@
import { FC } from 'react';
import { LocalizeText } from '../../../../api';
import { useCatalogActions, useCatalogUiState } from '../../../../hooks';
export const CatalogBreadcrumbView: FC<{}> = () =>
{
const { activeNodes = [] } = useCatalogUiState();
const { activateNode } = useCatalogActions();
if(!activeNodes || activeNodes.length === 0)
{
return (
<div className="nitro-catalog-classic-breadcrumb">
<span>{ LocalizeText('catalog.title') }</span>
</div>
);
}
return (
<div className="nitro-catalog-classic-breadcrumb">
{ activeNodes.map((node, index) => (
<span key={ node.pageId } className="nitro-catalog-classic-breadcrumb-segment">
<span className="nitro-catalog-classic-breadcrumb-separator">&rsaquo;</span>
<span
className={ `truncate ${ index === activeNodes.length - 1 ? 'font-semibold' : 'cursor-pointer hover:underline' }` }
onClick={ index < activeNodes.length - 1 ? () => activateNode(node) : undefined }
>
{ node.localization }
</span>
</span>
)) }
</div>
);
};
@@ -0,0 +1,146 @@
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 { useCatalogActions, useCatalogFavorites, useCatalogUiState } from '../../../../hooks';
import { useCatalogAdmin } from '../../CatalogAdminContext';
import { CatalogIconView } from '../catalog-icon/CatalogIconView';
import { CatalogNavigationSetView } from './CatalogNavigationSetView';
export interface CatalogNavigationItemViewProps
{
node: ICatalogNode;
child?: boolean;
}
export const CatalogNavigationItemView: FC<CatalogNavigationItemViewProps> = props =>
{
const { node = null, child = false } = props;
const { activateNode = null } = useCatalogActions();
const { currentType = CatalogType.NORMAL } = useCatalogUiState();
const catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
const { isFavoritePage, toggleFavoritePage } = useCatalogFavorites();
const isFav = node ? isFavoritePage(node.pageId) : false;
const [ isDragOver, setIsDragOver ] = useState(false);
const dragRef = useRef<HTMLDivElement>(null);
const handleDragStart = useCallback((e: React.DragEvent) =>
{
if(!adminMode) return;
e.dataTransfer.setData('text/plain', JSON.stringify({ pageId: node.pageId, parentId: node.parent?.pageId ?? -1 }));
e.dataTransfer.effectAllowed = 'move';
}, [ adminMode, node ]);
const handleDragOver = useCallback((e: React.DragEvent) =>
{
if(!adminMode) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setIsDragOver(true);
}, [ adminMode ]);
const handleDragLeave = useCallback(() =>
{
setIsDragOver(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) =>
{
if(!adminMode) return;
e.preventDefault();
setIsDragOver(false);
try
{
const data = JSON.parse(e.dataTransfer.getData('text/plain'));
if(data.pageId && data.pageId !== node.pageId)
{
// Drop onto a branch = reparent under this node
// Drop onto a leaf = reorder as sibling
const targetParentId = node.isBranch ? node.pageId : (node.parent?.pageId ?? -1);
const targetIndex = node.isBranch ? 0 : (node.parent?.children?.indexOf(node) ?? 0);
catalogAdmin?.reorderPage(data.pageId, targetParentId, targetIndex);
}
}
catch(err)
{
// Invalid drag data
}
}, [ adminMode, node, catalogAdmin ]);
return (
<div className={ `nitro-catalog-classic-navigation-node ${ child ? 'is-child' : '' }` }>
<div
ref={ dragRef }
className={ `nitro-catalog-classic-navigation-item group/nav ${ node.isActive ? 'is-active' : '' } ${ node.isBranch ? 'is-branch' : 'is-leaf' } ${ node.isOpen ? 'is-open' : '' } ${ isDragOver ? 'is-drag-over' : '' }` }
draggable={ adminMode }
onClick={ () => activateNode(node) }
onDragLeave={ adminMode ? handleDragLeave : undefined }
onDragOver={ adminMode ? handleDragOver : undefined }
onDragStart={ adminMode ? handleDragStart : undefined }
onDrop={ adminMode ? handleDrop : undefined }
>
{ adminMode &&
<FaArrowsAlt className="nitro-catalog-classic-navigation-drag text-[7px] text-muted cursor-grab shrink-0 opacity-0 group-hover/nav:opacity-60" /> }
<div className="nitro-catalog-classic-navigation-icon">
<CatalogIconView icon={ node.iconId } />
</div>
<span className="nitro-catalog-classic-navigation-label" title={ adminMode ? `Page ID: ${ node.pageId }` : undefined }>{ node.localization }</span>
{ adminMode &&
<div className="nitro-catalog-classic-navigation-admin flex items-center gap-1 opacity-0 group-hover/nav:opacity-100 transition-opacity">
<FaPlus
className="text-[8px] text-success hover:text-green-800"
title={ LocalizeText('catalog.admin.create.subpage') }
onClick={ e =>
{
e.stopPropagation();
catalogAdmin.createPage({
caption: 'New Page',
captionSave: 'New Page',
catalogMode: currentType,
pageLayout: 'default_3x3',
iconImage: 0,
minRank: 1,
visible: '1',
enabled: '1',
orderNum: 0,
parentId: node.pageId,
});
} }
/>
<FaTrash
className="text-[8px] text-danger hover:text-red-700"
title={ LocalizeText('catalog.admin.delete.page') }
onClick={ e =>
{
e.stopPropagation();
if(confirm(LocalizeText('catalog.admin.delete.page.confirm', [ 'name' ], [ node.localization ])))
{
catalogAdmin.deletePage(node.pageId);
}
} }
/>
</div> }
{ !adminMode && node.pageId > 0 &&
<FaStar
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">
{ node.isOpen ? <FaCaretUp /> : <FaCaretDown /> }
</span> }
</div>
{ node.isOpen && node.isBranch &&
<CatalogNavigationSetView child={ true } node={ node } /> }
</div>
);
};
@@ -0,0 +1,25 @@
import { FC } from 'react';
import { ICatalogNode } from '../../../../api';
import { CatalogNavigationItemView } from './CatalogNavigationItemView';
export interface CatalogNavigationSetViewProps
{
node: ICatalogNode;
child?: boolean;
}
export const CatalogNavigationSetView: FC<CatalogNavigationSetViewProps> = props =>
{
const { node = null, child = false } = props;
return (
<>
{ node && (node.children.length > 0) && node.children.map((n, index) =>
{
if(!n.isVisible) return null;
return <CatalogNavigationItemView key={ index } child={ child } node={ n } />;
}) }
</>
);
};
@@ -0,0 +1,27 @@
import { FC } from 'react';
import { ICatalogNode } from '../../../../api';
import { useCatalogData } from '../../../../hooks';
import { CatalogNavigationItemView } from './CatalogNavigationItemView';
import { CatalogNavigationSetView } from './CatalogNavigationSetView';
export interface CatalogNavigationViewProps
{
node: ICatalogNode;
}
export const CatalogNavigationView: FC<CatalogNavigationViewProps> = props =>
{
const { node = null } = props;
const { searchResult = null } = useCatalogData();
return (
<div className="nitro-catalog-classic-navigation-list">
{ searchResult && (searchResult.filteredNodes.length > 0) && searchResult.filteredNodes.map((n, index) =>
{
return <CatalogNavigationItemView key={ index } node={ n } />;
}) }
{ !searchResult &&
<CatalogNavigationSetView node={ node } /> }
</div>
);
};
@@ -0,0 +1,165 @@
import { MouseEventType } from '@nitrots/nitro-renderer';
import { FC, MouseEvent, useMemo, useState } from 'react';
import { FaHeart } from 'react-icons/fa';
import { CatalogType, GetConfigurationValue, IPurchasableOffer, Offer, ProductTypeEnum } from '../../../../../api';
import { LayoutAvatarImageView, LayoutGridItem, LayoutGridItemProps } from '../../../../../common';
import { useCatalogActions, useCatalogFavorites, useCatalogUiState, useInventoryFurni } from '../../../../../hooks';
interface CatalogGridOfferViewProps extends LayoutGridItemProps
{
offer: IPurchasableOffer;
selectOffer: (offer: IPurchasableOffer) => void;
}
export const CatalogGridOfferView: FC<CatalogGridOfferViewProps> = props =>
{
const { offer = null, selectOffer = null, itemActive = false, ...rest } = props;
const [ isMouseDown, setMouseDown ] = useState(false);
const { requestOfferToMover = null } = useCatalogActions();
const { currentType = CatalogType.NORMAL } = useCatalogUiState();
const { isVisible = false } = useInventoryFurni();
const { isFavoriteOffer, toggleFavoriteOffer } = useCatalogFavorites();
const isFav = offer ? isFavoriteOffer(offer.offerId) : false;
const iconUrl = useMemo(() =>
{
if(!offer) return null;
if(offer.pricingModel === Offer.PRICING_MODEL_BUNDLE)
{
return null;
}
const product = offer.product;
if(!product) return null;
if((product.productType === ProductTypeEnum.FLOOR) || (product.productType === ProductTypeEnum.WALL))
{
const className = product.furnitureData?.className;
if(className?.length)
{
let param = '';
if(product.productType === ProductTypeEnum.WALL && product.extraParam?.length)
{
param = `_${ product.extraParam }`;
}
else if(product.productType === ProductTypeEnum.FLOOR && product.furnitureData?.hasIndexedColor && (product.furnitureData.colorIndex > 0))
{
param = `_${ product.furnitureData.colorIndex }`;
}
const configuredIconUrl = GetConfigurationValue<string>('furni.asset.icon.url', '');
if(configuredIconUrl?.length)
{
return configuredIconUrl
.replace('%libname%', className)
.replace('%param%', param);
}
}
}
return product.getIconUrl(offer) ?? null;
}, [ offer ]);
const prices = useMemo(() =>
{
if(!offer) return [];
const values: { amount: number; type: number }[] = [];
if(offer.priceInCredits > 0) values.push({ amount: offer.priceInCredits, type: -1 });
if(offer.priceInActivityPoints > 0) values.push({ amount: offer.priceInActivityPoints, type: offer.activityPointType });
return values;
}, [ offer ]);
const getCurrencyIconUrl = (type: number) =>
{
const configuredCurrencyUrl = GetConfigurationValue<string>('currency.asset.icon.url', '');
return configuredCurrencyUrl.replace('%type%', type.toString());
};
const onMouseEvent = (event: MouseEvent) =>
{
switch(event.type)
{
case MouseEventType.MOUSE_DOWN:
selectOffer(offer);
setMouseDown(true);
return;
case MouseEventType.MOUSE_UP:
setMouseDown(false);
return;
case MouseEventType.ROLL_OUT:
if(!isMouseDown || !itemActive) return;
if(currentType === CatalogType.BUILDER) return;
if(!isVisible) return;
requestOfferToMover(offer);
return;
}
};
if(!offer) return null;
const product = offer.product;
if(!product) return null;
return (
<LayoutGridItem
className={ `group/tile relative ${ itemActive ? 'is-active' : '' }` }
gap={ 1 }
itemActive={ itemActive }
itemCount={ ((offer.pricingModel === Offer.PRICING_MODEL_MULTI) ? product.productCount : 1) }
itemUniqueNumber={ product.uniqueLimitedItemSeriesSize }
itemUniqueSoldout={ (product.uniqueLimitedItemSeriesSize && !product.uniqueLimitedItemsLeft) }
title={ `ID: ${ product.productClassId } | Offer: ${ offer.offerId }` }
onMouseDown={ onMouseEvent }
onMouseOut={ onMouseEvent }
onMouseUp={ onMouseEvent }
{ ...rest }
>
{ iconUrl && !(offer.product.productType === ProductTypeEnum.ROBOT) &&
<img
className="nitro-catalog-classic-grid-offer-icon"
src={ iconUrl }
draggable={ false }
onError={ event =>
{
const fallbackIconUrl = product.getIconUrl(offer);
if(fallbackIconUrl && (event.currentTarget.src !== fallbackIconUrl)) event.currentTarget.src = fallbackIconUrl;
} } /> }
{ (offer.product.productType === ProductTypeEnum.ROBOT) &&
<LayoutAvatarImageView direction={ 2 } figure={ offer.product.extraParam } fit /> }
{ (prices.length > 0) &&
<span className={ `nitro-catalog-classic-grid-price ${ prices.length > 1 ? 'is-multi-price' : 'is-single-price' }` }>
{ prices.map((price, index) =>
<span key={ `${ price.type }-${ index }` } className="nitro-catalog-classic-grid-price-entry">
{ index > 0 && <span className="nitro-catalog-classic-grid-price-plus">+</span> }
<span className="nitro-catalog-classic-grid-price-amount">{ price.amount }</span>
<img
className="nitro-catalog-classic-grid-price-currency"
src={ getCurrencyIconUrl(price.type) }
draggable={ false } />
</span>) }
</span> }
<div
className={ `absolute top-0 right-0 z-10 p-0.5 cursor-pointer transition-opacity duration-100 ${ isFav ? 'opacity-100' : 'opacity-0 group-hover/tile:opacity-100' }` }
onClick={ e =>
{
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' }` } />
</div>
</LayoutGridItem>
);
};
@@ -0,0 +1,67 @@
import { RedeemVoucherMessageComposer, VoucherRedeemErrorMessageEvent, VoucherRedeemOkMessageEvent } from '@nitrots/nitro-renderer';
import { FC, useState } from 'react';
import { FaTag } from 'react-icons/fa';
import { LocalizeText, SendMessageComposer } from '../../../../../api';
import { Button } from '../../../../../common';
import { useMessageEvent, useNotification } from '../../../../../hooks';
import { NitroInput } from '../../../../../layout';
export interface CatalogRedeemVoucherViewProps
{
text: string;
}
export const CatalogRedeemVoucherView: FC<CatalogRedeemVoucherViewProps> = props =>
{
const { text = null } = props;
const [ voucher, setVoucher ] = useState<string>('');
const [ isWaiting, setIsWaiting ] = useState(false);
const { simpleAlert = null } = useNotification();
const redeemVoucher = () =>
{
if(!voucher || !voucher.length || isWaiting) return;
SendMessageComposer(new RedeemVoucherMessageComposer(voucher));
setIsWaiting(true);
};
useMessageEvent<VoucherRedeemOkMessageEvent>(VoucherRedeemOkMessageEvent, event =>
{
const parser = event.getParser();
let message = LocalizeText('catalog.alert.voucherredeem.ok.description');
if(parser.productName) message = LocalizeText('catalog.alert.voucherredeem.ok.description.furni', [ 'productName', 'productDescription' ], [ parser.productName, parser.productDescription ]);
simpleAlert(message, null, null, null, LocalizeText('catalog.alert.voucherredeem.ok.title'));
setIsWaiting(false);
setVoucher('');
});
useMessageEvent<VoucherRedeemErrorMessageEvent>(VoucherRedeemErrorMessageEvent, event =>
{
const parser = event.getParser();
simpleAlert(LocalizeText(`catalog.alert.voucherredeem.error.description.${ parser.errorCode }`), null, null, null, LocalizeText('catalog.alert.voucherredeem.error.title'));
setIsWaiting(false);
});
return (
<div className="flex gap-1">
<NitroInput
placeholder={ text }
value={ voucher }
onChange={ event => setVoucher(event.target.value) } />
<Button disabled={ isWaiting } variant="primary" onClick={ redeemVoucher }>
<FaTag className="fa-icon" />
</Button>
</div>
);
};
@@ -0,0 +1,115 @@
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 { useCatalogData, useCatalogUiState } from '../../../../../hooks';
export const CatalogSearchView: FC<{}> = () =>
{
const [ searchValue, setSearchValue ] = useState('');
const { rootNode = null, searchResult = null } = useCatalogData();
const { currentType = null, setSearchResult = null, setCurrentPage = null } = useCatalogUiState();
const normalizeSearchText = (value: string) => (value || '')
.toLocaleLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, ' ')
.trim();
useEffect(() =>
{
const search = normalizeSearchText(searchValue);
if(!search || !search.length)
{
setSearchResult(null);
return;
}
const timeout = setTimeout(() =>
{
if(!rootNode) return;
const furnitureDatas = GetSessionDataManager().getAllFurnitureData();
if(!furnitureDatas || !furnitureDatas.length) return;
const foundFurniture: IFurnitureData[] = [];
const foundFurniLines: string[] = [];
for(const furniture of furnitureDatas)
{
if(!furniture) continue;
if((currentType === CatalogType.BUILDER) && !furniture.availableForBuildersClub) continue;
if((currentType === CatalogType.NORMAL) && furniture.excludeDynamic) continue;
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))
{
if((furniture.furniLine !== '') && (foundFurniLines.indexOf(furniture.furniLine) < 0))
{
if(matchesSearch) foundFurniLines.push(furniture.furniLine);
}
}
else if(matchesSearch && isBuyable)
{
foundFurniture.push(furniture);
if(furniture.furniLine && furniture.furniLine.length && (foundFurniLines.indexOf(furniture.furniLine) < 0))
{
foundFurniLines.push(furniture.furniLine);
}
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[] = [];
for(const furniture of foundFurniture)
{
offers.push(new FurnitureOffer(furniture));
}
let nodes: ICatalogNode[] = [];
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)));
}, 300);
return () => clearTimeout(timeout);
}, [ currentType, rootNode, searchValue, setCurrentPage, setSearchResult ]);
return (
<div className="relative w-full">
<FaSearch className="absolute left-2 top-1/2 -translate-y-1/2 text-[9px] text-muted pointer-events-none" />
<input
className="w-full pl-6 pr-6 py-[3px] text-[11px] rounded border-2 border-card-grid-item-border bg-white text-dark placeholder-muted focus:outline-none focus:border-primary transition-colors"
placeholder={ LocalizeText('generic.search') }
type="text"
value={ searchValue }
onChange={ e => setSearchValue(e.target.value) }
/>
{ searchValue && searchValue.length > 0 &&
<button
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-[9px] text-muted hover:text-danger cursor-pointer transition-colors"
onClick={ () => setSearchValue('') }
>
<FaTimes />
</button> }
</div>
);
};
@@ -0,0 +1,40 @@
import { GetConfigurationValue, IPurchasableOffer, ProductTypeEnum } from '../../../../../api';
// Su questo renderer product.getIconUrl() ritorna un path che va in 404 (icone
// dcr/hof_furni mancanti). Per floor/wall costruiamo l'URL icona dalla config
// furni.asset.icon.url (%libname%/%param%), come fa il catalogo classico; per il
// resto (badge, robot, ecc.) si torna al getIconUrl del renderer.
export const getFurniIconUrl = (product: any, offer: IPurchasableOffer = null): string =>
{
if(!product) return null;
if((product.productType === ProductTypeEnum.FLOOR) || (product.productType === ProductTypeEnum.WALL))
{
const className = product.furnitureData?.className;
if(className?.length)
{
let param = '';
if(product.productType === ProductTypeEnum.WALL && product.extraParam?.length)
{
param = `_${ product.extraParam }`;
}
else if(product.productType === ProductTypeEnum.FLOOR && product.furnitureData?.hasIndexedColor && (product.furnitureData.colorIndex > 0))
{
param = `_${ product.furnitureData.colorIndex }`;
}
const configuredIconUrl = GetConfigurationValue<string>('furni.asset.icon.url', '');
if(configuredIconUrl?.length)
{
return configuredIconUrl
.replace('%libname%', className)
.replace('%param%', param);
}
}
}
return product.getIconUrl(offer) ?? null;
};
@@ -0,0 +1,7 @@
import { ICatalogPage } from '../../../../../api';
export interface CatalogLayoutProps
{
page: ICatalogPage;
hideNavigation: () => void;
}
@@ -0,0 +1,54 @@
import { FC } from 'react';
import { LocalizeText, SanitizeHtml } from '../../../../../api';
import { Column, Grid, Text } from '../../../../../common';
import { useCatalogData } from '../../../../../hooks';
import { CatalogBadgeSelectorWidgetView } from '../widgets/CatalogBadgeSelectorWidgetView';
import { CatalogFirstProductSelectorWidgetView } from '../widgets/CatalogFirstProductSelectorWidgetView';
import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView';
import { CatalogLimitedItemWidgetView } from '../widgets/CatalogLimitedItemWidgetView';
import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView';
import { CatalogTotalPriceWidget } from '../widgets/CatalogTotalPriceWidget';
import { CatalogViewProductWidgetView } from '../widgets/CatalogViewProductWidgetView';
import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayoutBadgeDisplayView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const { currentOffer = null } = useCatalogData();
return (
<>
<CatalogFirstProductSelectorWidgetView />
<Grid>
<Column overflow="hidden" size={ 7 }>
<CatalogItemGridWidgetView shrink />
<Column gap={ 1 } overflow="hidden">
<Text shrink truncate fontWeight="bold">{ LocalizeText('catalog_selectbadge') }</Text>
<CatalogBadgeSelectorWidgetView />
</Column>
</Column>
<Column center={ !currentOffer } overflow="hidden" size={ 5 }>
{ !currentOffer &&
<>
{ !!page.localization.getImage(1) && <img alt="" src={ page.localization.getImage(1) } /> }
<Text center dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } />
</> }
{ currentOffer &&
<>
<div className="relative overflow-hidden">
<CatalogViewProductWidgetView />
</div>
<Column className="grow!" gap={ 1 }>
<CatalogLimitedItemWidgetView />
<Text truncate className="grow!">{ currentOffer.localizationName }</Text>
<div className="flex justify-end">
<CatalogTotalPriceWidget alignItems="end" />
</div>
<CatalogPurchaseWidgetView />
</Column>
</> }
</Column>
</Grid>
</>
);
};
@@ -0,0 +1,36 @@
import { FC, useEffect } from 'react';
import { SanitizeHtml } from '../../../../../api';
import { CatalogLayoutProps } from './CatalogLayout.types';
// Info/landing layout: a logo box on top (image scaled to fit the available
// space, no crop) and a smaller box below with the page text in black.
// Logo = page headline image (getImage(0)), text = page text 1 (getText(0)),
// set from catalog admin (Gestione -> Modifica pagina). Hides the (empty)
// navigation sidebar so the content uses the full width.
export const CatalogLayoutBcInfoView: FC<CatalogLayoutProps> = props =>
{
const { page = null, hideNavigation = null } = props;
const logo = page?.localization?.getImage(0) || '';
const text = page?.localization?.getText(0) || '';
useEffect(() =>
{
hideNavigation?.();
}, [ page, hideNavigation ]);
return (
<div className="flex flex-col h-full gap-2">
<div className="flex-1 min-h-0 bg-white rounded border border-card-grid-item-border overflow-hidden flex items-center justify-center">
{ logo
? <img alt="" className="max-w-full max-h-full object-contain" src={ logo } />
: <span className="text-muted text-[11px]">Logo imposta l'immagine headline da Gestione</span> }
</div>
<div className="shrink-0 max-h-[32%] bg-white rounded border border-card-grid-item-border p-3 overflow-auto">
<div
className="text-black text-[12px] leading-snug"
dangerouslySetInnerHTML={ { __html: SanitizeHtml(text) } } />
</div>
</div>
);
};
@@ -0,0 +1,205 @@
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 { useCatalogData, useClubOffers, usePurse, useUiEvent } from '../../../../../hooks';
import { CatalogHeaderView } from '../../catalog-header/CatalogHeaderView';
import { CatalogLayoutProps } from './CatalogLayout.types';
const BUILDERS_CLUB_WINDOW_ID = 2;
const BUILDERS_CLUB_ADDONS_WINDOW_ID = 3;
export const CatalogLayoutBuildersClubBuyView: FC<CatalogLayoutProps> = () =>
{
const [ pendingOffer, setPendingOffer ] = useState<ClubOfferData>(null);
const [ purchaseState, setPurchaseState ] = useState(CatalogPurchaseState.NONE);
const { currentPage = null } = 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 { data: offers = null } = useClubOffers(windowId);
const onCatalogEvent = useCallback((event: CatalogEvent) =>
{
switch(event.type)
{
case CatalogPurchasedEvent.PURCHASE_SUCCESS:
isPurchasingRef.current = false;
setPurchaseState(CatalogPurchaseState.NONE);
return;
case CatalogPurchaseFailureEvent.PURCHASE_FAILED:
isPurchasingRef.current = false;
setPurchaseState(CatalogPurchaseState.FAILED);
return;
}
}, []);
useUiEvent(CatalogPurchasedEvent.PURCHASE_SUCCESS, onCatalogEvent);
useUiEvent(CatalogPurchaseFailureEvent.PURCHASE_FAILED, onCatalogEvent);
const getOfferTotalUnits = useCallback((offer: ClubOfferData) =>
{
if(!offer) return 0;
return ((offer.months * 31) + offer.extraDays);
}, []);
const getOfferName = useCallback((offer: ClubOfferData) =>
{
if(!offer) return '';
const localized = LocalizeText(offer.productCode);
if(localized && (localized !== offer.productCode)) return localized;
return offer.productCode.replace(/_/g, ' ');
}, []);
const getOfferMeta = useCallback((offer: ClubOfferData) =>
{
if(!offer) return '';
if(isAddonLayout)
{
const units = getOfferTotalUnits(offer);
return (units > 0) ? `+${ units }` : '';
}
const parts: string[] = [];
if(offer.months > 0) parts.push(LocalizeText('catalog.vip.item.header.months', [ 'num_months' ], [ offer.months.toString() ]));
if(offer.extraDays > 0) parts.push(LocalizeText('catalog.vip.item.header.days', [ 'num_days' ], [ offer.extraDays.toString() ]));
return parts.join(' ');
}, [ getOfferTotalUnits, isAddonLayout ]);
const purchaseOffer = useCallback(() =>
{
if(!pendingOffer || !currentPage || isPurchasingRef.current) return;
isPurchasingRef.current = true;
setPurchaseState(CatalogPurchaseState.PURCHASE);
SendMessageComposer(new PurchaseFromCatalogComposer(currentPage.pageId, pendingOffer.offerId, null, 1));
}, [ pendingOffer, currentPage ]);
const getPurchaseButton = useCallback(() =>
{
if(!pendingOffer) return null;
if(pendingOffer.priceCredits > getCurrencyAmount(-1))
{
return <Button fullWidth variant="danger">{ LocalizeText('catalog.alert.notenough.title') }</Button>;
}
if(pendingOffer.priceActivityPoints > getCurrencyAmount(pendingOffer.priceActivityPointsType))
{
return <Button fullWidth variant="danger">{ LocalizeText(`catalog.alert.notenough.activitypoints.title.${ pendingOffer.priceActivityPointsType }`) }</Button>;
}
switch(purchaseState)
{
case CatalogPurchaseState.CONFIRM:
return <Button fullWidth variant="warning" onClick={ purchaseOffer }>{ LocalizeText('catalog.marketplace.confirm_title') }</Button>;
case CatalogPurchaseState.PURCHASE:
return <Button disabled fullWidth variant="primary"><LayoutLoadingSpinnerView /></Button>;
case CatalogPurchaseState.FAILED:
return <Button disabled fullWidth variant="danger">{ LocalizeText('generic.failed') }</Button>;
case CatalogPurchaseState.NONE:
default:
return <Button fullWidth variant="success" onClick={ () => setPurchaseState(CatalogPurchaseState.CONFIRM) }>{ LocalizeText('buy') }</Button>;
}
}, [ getCurrencyAmount, pendingOffer, purchaseOffer, purchaseState ]);
const pageDescription = useMemo(() =>
{
if(!currentPage) return '';
return currentPage.localization.getText(1) || currentPage.localization.getText(2) || currentPage.localization.getText(0) || '';
}, [ currentPage ]);
useEffect(() =>
{
if(!offers || !offers.length) return;
setPendingOffer(prevValue =>
{
if(prevValue && offers.some(offer => (offer.offerId === prevValue.offerId))) return prevValue;
return offers[0];
});
}, [ offers ]);
return (
<div className="flex h-full flex-col gap-2">
{ currentPage?.localization?.getImage(0) &&
<CatalogHeaderView imageUrl={ currentPage.localization.getImage(0) } /> }
<Grid>
<Column fullHeight justifyContent="between" overflow="hidden" size={ 7 }>
<Column gap={ 1 } overflow="auto">
{ offers && (offers.length > 0) && offers.map((offer, index) =>
{
const meta = getOfferMeta(offer);
return (
<LayoutGridItem key={ index } alignItems="center" center={ false } className="p-2" column={ false } itemActive={ pendingOffer?.offerId === offer.offerId } justifyContent="between" onClick={ () =>
{
setPurchaseState(CatalogPurchaseState.NONE);
setPendingOffer(offer);
} }>
<Column gap={ 0 }>
<Text fontWeight="bold">{ getOfferName(offer) }</Text>
{ meta.length > 0 && <Text small>{ meta }</Text> }
</Column>
<div className="flex flex-col gap-1">
{ (offer.priceCredits > 0) &&
<Flex alignItems="center" gap={ 1 } justifyContent="end">
<Text>{ offer.priceCredits }</Text>
<LayoutCurrencyIcon type={ -1 } />
</Flex> }
{ (offer.priceActivityPoints > 0) &&
<Flex alignItems="center" gap={ 1 } justifyContent="end">
<Text>{ offer.priceActivityPoints }</Text>
<LayoutCurrencyIcon type={ offer.priceActivityPointsType } />
</Flex> }
</div>
</LayoutGridItem>
);
}) }
</Column>
</Column>
<Column gap={ 2 } overflow="hidden" size={ 5 }>
<Column center grow overflow="hidden">
{ currentPage?.localization.getImage(1) && <img alt="" src={ currentPage.localization.getImage(1) } /> }
{ pageDescription.length > 0 && <Text center dangerouslySetInnerHTML={ { __html: SanitizeHtml(pageDescription) } } overflow="auto" /> }
</Column>
{ pendingOffer &&
<Column fullWidth gap={ 1 }>
<Text fontWeight="bold">{ getOfferName(pendingOffer) }</Text>
{ getOfferMeta(pendingOffer).length > 0 && <Text>{ getOfferMeta(pendingOffer) }</Text> }
<Flex alignItems="end">
<Column grow gap={ 0 }>
<Text>{ currentPage?.localization.getText(0) || '' }</Text>
</Column>
<div className="flex flex-col gap-1">
{ (pendingOffer.priceCredits > 0) &&
<Flex alignItems="center" gap={ 1 } justifyContent="end">
<Text>{ pendingOffer.priceCredits }</Text>
<LayoutCurrencyIcon type={ -1 } />
</Flex> }
{ (pendingOffer.priceActivityPoints > 0) &&
<Flex alignItems="center" gap={ 1 } justifyContent="end">
<Text>{ pendingOffer.priceActivityPoints }</Text>
<LayoutCurrencyIcon type={ pendingOffer.priceActivityPointsType } />
</Flex> }
</div>
</Flex>
{ getPurchaseButton() }
</Column> }
</Column>
</Grid>
</div>
);
};
@@ -0,0 +1,177 @@
import { ColorConverter } from '@nitrots/nitro-renderer';
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 { useCatalogData, useCatalogUiState } from '../../../../../hooks';
import { CatalogGridOfferView } from '../common/CatalogGridOfferView';
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
import { CatalogLimitedItemWidgetView } from '../widgets/CatalogLimitedItemWidgetView';
import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView';
import { CatalogSpinnerWidgetView } from '../widgets/CatalogSpinnerWidgetView';
import { CatalogTotalPriceWidget } from '../widgets/CatalogTotalPriceWidget';
import { CatalogViewProductWidgetView } from '../widgets/CatalogViewProductWidgetView';
import { CatalogLayoutProps } from './CatalogLayout.types';
export interface CatalogLayoutColorGroupViewProps extends CatalogLayoutProps
{
}
export const CatalogLayoutColorGroupingView: FC<CatalogLayoutColorGroupViewProps> = props =>
{
const { page = null } = props;
const [ colorableItems, setColorableItems ] = useState<Map<string, number[]>>(new Map<string, number[]>());
const { currentOffer = null } = useCatalogData();
const { setCurrentOffer = null } = useCatalogUiState();
const [ colorsShowing, setColorsShowing ] = useState<boolean>(false);
const sortByColorIndex = (a: IPurchasableOffer, b: IPurchasableOffer) =>
{
if(((!(a.product.furnitureData.colorIndex)) || (!(b.product.furnitureData.colorIndex))))
{
return 1;
}
if(a.product.furnitureData.colorIndex > b.product.furnitureData.colorIndex)
{
return 1;
}
if(a == b)
{
return 0;
}
return -1;
};
const sortyByFurnitureClassName = (a: IPurchasableOffer, b: IPurchasableOffer) =>
{
if(a.product.furnitureData.className > b.product.furnitureData.className)
{
return 1;
}
if(a == b)
{
return 0;
}
return -1;
};
const selectOffer = (offer: IPurchasableOffer) =>
{
offer.activate();
setCurrentOffer(offer);
};
const selectColor = (colorIndex: number, productName: string) =>
{
const fullName = `${ productName }*${ colorIndex }`;
const index = page.offers.findIndex(offer => offer.product.furnitureData.fullName === fullName);
if(index > -1)
{
selectOffer(page.offers[index]);
}
};
const offers = useMemo(() =>
{
const offers: IPurchasableOffer[] = [];
const addedColorableItems = new Map<string, boolean>();
const updatedColorableItems = new Map<string, number[]>();
page.offers.sort(sortByColorIndex);
page.offers.forEach(offer =>
{
if(!offer.product) return;
const furniData = offer.product.furnitureData;
if(!furniData || !furniData.hasIndexedColor)
{
offers.push(offer);
}
else
{
const name = furniData.className;
const colorIndex = furniData.colorIndex;
if(!updatedColorableItems.has(name))
{
updatedColorableItems.set(name, []);
}
let selectedColor = 0xFFFFFF;
if(furniData.colors)
{
for(let color of furniData.colors)
{
if(color !== 0xFFFFFF) // skip the white colors
{
selectedColor = color;
}
}
if(updatedColorableItems.get(name).indexOf(selectedColor) === -1)
{
updatedColorableItems.get(name)[colorIndex] = selectedColor;
}
}
if(!addedColorableItems.has(name))
{
offers.push(offer);
addedColorableItems.set(name, true);
}
}
});
offers.sort(sortyByFurnitureClassName);
setColorableItems(updatedColorableItems);
return offers;
}, [ page.offers ]);
return (
<Grid>
<Column overflow="hidden" size={ 7 }>
<AutoGrid columnCount={ 5 }>
{ (!colorsShowing || !currentOffer || !colorableItems.has(currentOffer.product.furnitureData.className)) &&
offers.map((offer, index) => <CatalogGridOfferView key={ index } itemActive={ (currentOffer && (currentOffer.product.furnitureData.hasIndexedColor ? currentOffer.product.furnitureData.className === offer.product.furnitureData.className : currentOffer.offerId === offer.offerId)) } offer={ offer } selectOffer={ selectOffer } />)
}
{ (colorsShowing && currentOffer && colorableItems.has(currentOffer.product.furnitureData.className)) &&
colorableItems.get(currentOffer.product.furnitureData.className).map((color, index) => <LayoutGridItem key={ index } itemHighlight className="clear-bg" itemActive={ (currentOffer.product.furnitureData.colorIndex === index) } itemColor={ ColorConverter.int2rgb(color) } onClick={ event => selectColor(index, currentOffer.product.furnitureData.className) } />)
}
</AutoGrid>
</Column>
<Column center={ !currentOffer } overflow="hidden" size={ 5 }>
{ !currentOffer &&
<>
{ !!page.localization.getImage(1) && <img alt="" src={ page.localization.getImage(1) } /> }
<Text center dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } />
</> }
{ currentOffer &&
<>
<div className="relative overflow-hidden">
<CatalogViewProductWidgetView />
<CatalogAddOnBadgeWidgetView className="bg-muted rounded bottom-1 inset-e-1" position="absolute" />
{ currentOffer.product.furnitureData.hasIndexedColor &&
<Button className="bottom-1 inset-s-1" position="absolute" onClick={ event => setColorsShowing(prev => !prev) }>
<FaFillDrip className="fa-icon" />
</Button> }
</div>
<Column className="grow!" gap={ 1 }>
<CatalogLimitedItemWidgetView />
<Text truncate className="grow!">{ currentOffer.localizationName }</Text>
<div className="flex justify-between">
<div className="flex flex-col gap-1">
<CatalogSpinnerWidgetView />
</div>
<CatalogTotalPriceWidget alignItems="end" justifyContent="end" />
</div>
<CatalogPurchaseWidgetView />
</Column>
</> }
</Column>
</Grid>
);
};
@@ -0,0 +1,452 @@
import { PurchasePrefixComposer } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useRef, useState } from 'react';
import { LocalizeText, SanitizeHtml, SendMessageComposer, PRESET_PREFIX_EFFECTS, parsePrefixColors, getPrefixEffectStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../../api';
import { CatalogLayoutProps } from './CatalogLayout.types';
import data from '@emoji-mart/data';
import Picker from '@emoji-mart/react';
const PRESET_COLORS: string[] = [
'#FF0000', '#FF6600', '#FFCC00', '#33CC00', '#00CCFF',
'#0066FF', '#9933FF', '#FF33CC', '#FFFFFF', '#CCCCCC',
'#999999', '#333333', '#FF9999', '#99FF99', '#9999FF',
'#FFD700', '#FF4500', '#00CED1', '#8A2BE2', '#DC143C'
];
export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
{
const { page = null, hideNavigation = null } = props;
useEffect(() =>
{
hideNavigation();
}, [ page, hideNavigation ]);
const [ prefixText, setPrefixText ] = useState('');
const [ colorMode, setColorMode ] = useState<'single' | 'perLetter'>('single');
const [ singleColor, setSingleColor ] = useState('#FFFFFF');
const [ letterColors, setLetterColors ] = useState<Record<number, string>>({});
const [ selectedLetterIndex, setSelectedLetterIndex ] = useState<number | null>(null);
const [ customColorInput, setCustomColorInput ] = useState('#FFFFFF');
const [ selectedIcon, setSelectedIcon ] = useState('');
const [ showIconPicker, setShowIconPicker ] = useState(false);
const [ selectedEffect, setSelectedEffect ] = useState('');
const [ purchased, setPurchased ] = useState(false);
const colorString = useMemo(() =>
{
if(colorMode === 'single') return singleColor;
if(!prefixText.length) return singleColor;
return [ ...prefixText ].map((_, i) => letterColors[i] || singleColor).join(',');
}, [ colorMode, singleColor, letterColors, prefixText ]);
const previewColors = useMemo(() =>
{
return parsePrefixColors(prefixText || '...', colorString || '#FFFFFF');
}, [ prefixText, colorString ]);
const isValid = useMemo(() =>
{
if(!prefixText.trim().length || prefixText.trim().length > 15) return false;
if(colorMode === 'single') return /^#[0-9A-Fa-f]{6}$/.test(singleColor);
const colors = colorString.split(',');
return colors.every(c => /^#[0-9A-Fa-f]{6}$/.test(c));
}, [ prefixText, colorMode, singleColor, colorString ]);
const handlePurchase = () =>
{
if(!isValid) return;
SendMessageComposer(new PurchasePrefixComposer(prefixText.trim(), colorString, selectedIcon, selectedEffect));
setPurchased(true);
setTimeout(() => setPurchased(false), 2000);
};
const handleColorSelect = (color: string) =>
{
if(colorMode === 'single')
{
setSingleColor(color);
setCustomColorInput(color);
}
else if(selectedLetterIndex !== null)
{
setLetterColors(prev => ({ ...prev, [selectedLetterIndex]: color }));
setCustomColorInput(color);
// Auto-avanza alla lettera successiva
if(selectedLetterIndex < prefixText.length - 1)
{
const nextIdx = selectedLetterIndex + 1;
setSelectedLetterIndex(nextIdx);
setCustomColorInput(letterColors[nextIdx] || singleColor);
}
}
};
const handleCustomColorChange = (value: string) =>
{
setCustomColorInput(value);
if(/^#[0-9A-Fa-f]{6}$/.test(value))
{
if(colorMode === 'single')
{
setSingleColor(value);
}
else if(selectedLetterIndex !== null)
{
setLetterColors(prev => ({ ...prev, [selectedLetterIndex]: value }));
}
}
};
const handleTextChange = (newText: string) =>
{
setPrefixText(newText);
if(selectedLetterIndex !== null && selectedLetterIndex >= newText.length)
{
setSelectedLetterIndex(newText.length > 0 ? newText.length - 1 : null);
}
};
const applyColorToAll = () =>
{
if(!prefixText.length) return;
const newColors: Record<number, string> = {};
[ ...prefixText ].forEach((_, i) =>
{
newColors[i] = customColorInput;
});
setLetterColors(newColors);
};
const hasMultiColor = colorMode === 'perLetter' && previewColors.length > 1 && new Set(previewColors).size > 1;
const currentActiveColor = colorMode === 'single'
? singleColor
: (selectedLetterIndex !== null ? (letterColors[selectedLetterIndex] || singleColor) : singleColor);
const effectStyle = getPrefixEffectStyle(selectedEffect, previewColors[0] || '#FFFFFF');
return (
<div className="flex flex-col gap-2 h-full overflow-auto p-1">
<style>{ PREFIX_EFFECT_KEYFRAMES }</style>
{ /* Header */ }
{ page.localization.getImage(0) &&
<img alt="" className="w-full rounded" src={ page.localization.getImage(0) } /> }
{ page.localization.getText(0) &&
<div className="text-sm mb-1" dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } /> }
{ /* Live Preview */ }
<div className="relative flex items-center justify-center p-4 rounded-lg min-h-[56px]"
style={ {
background: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)',
border: '1px solid rgba(255,255,255,0.08)',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.05), 0 2px 8px rgba(0,0,0,0.3)'
} }>
<div className="absolute inset-0 rounded-lg opacity-20"
style={ { background: 'radial-gradient(ellipse at center, rgba(100,149,237,0.3) 0%, transparent 70%)' } } />
<span className="relative text-xl font-bold tracking-wide" style={ effectStyle }>
{ selectedIcon && <span className="mr-1">{ selectedIcon }</span> }
<span style={ hasMultiColor ? effectStyle : { ...effectStyle, color: previewColors[0] || '#FFFFFF' } }>
{'{'}
{ hasMultiColor
? [ ...(prefixText || '...') ].map((char, i) => (
<span key={ i } style={ { color: previewColors[i] || previewColors[previewColors.length - 1], ...getPrefixEffectStyle(selectedEffect, previewColors[i]) } }>{ char }</span>
))
: (prefixText || '...')
}
{'}'}
</span>
</span>
<span className="relative ml-2 text-white/80 text-lg font-medium">Username</span>
</div>
{ /* Text + Icon Row */ }
<div className="flex gap-2">
<div className="flex flex-col gap-0.5 flex-1">
<label className="text-[11px] font-bold uppercase tracking-wider opacity-60">{ LocalizeText('catalog.prefix.text') }</label>
<div className="relative">
<input
className="w-full px-3 py-1.5 rounded-md text-sm focus:outline-none transition-all"
maxLength={ 15 }
placeholder={ LocalizeText('catalog.prefix.text.placeholder') }
style={ {
background: 'rgba(0,0,0,0.15)',
border: '1px solid rgba(0,0,0,0.15)',
color: 'inherit'
} }
type="text"
value={ prefixText }
onChange={ e => handleTextChange(e.target.value) } />
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] opacity-30 font-mono">
{ prefixText.length }/15
</span>
</div>
</div>
<div className="flex flex-col gap-0.5 relative">
<label className="text-[11px] font-bold uppercase tracking-wider opacity-60">{ LocalizeText('catalog.prefix.icon') }</label>
<div className="flex gap-1">
<button
className="flex items-center justify-center gap-1 px-3 py-1.5 rounded-md text-sm transition-all min-w-[70px]"
style={ {
background: selectedIcon ? 'rgba(59,130,246,0.15)' : 'rgba(0,0,0,0.15)',
border: selectedIcon ? '1px solid rgba(59,130,246,0.3)' : '1px solid rgba(0,0,0,0.15)'
} }
onClick={ () => setShowIconPicker(!showIconPicker) }>
{ selectedIcon
? <><span className="text-base">{ selectedIcon }</span><span className="text-[10px] opacity-40"></span></>
: <span className="opacity-40 text-xs">Emoji </span>
}
</button>
{ selectedIcon &&
<button
className="flex items-center justify-center px-1.5 rounded-md text-xs transition-all"
style={ { background: 'rgba(239,68,68,0.15)', border: '1px solid rgba(239,68,68,0.3)' } }
title={ LocalizeText('catalog.prefix.icon.remove') }
onClick={ () => setSelectedIcon('') }>
</button>
}
</div>
</div>
</div>
{ /* Emoji Picker (emoji-mart) - fixed overlay */ }
{ showIconPicker && (
<>
<div className="fixed inset-0" style={ { zIndex: 999, background: 'rgba(0,0,0,0.5)' } } onClick={ () => setShowIconPicker(false) } />
<div className="fixed rounded-xl overflow-hidden" style={ { zIndex: 1000, top: '50%', left: '50%', transform: 'translate(-50%, -50%)', boxShadow: '0 8px 32px rgba(0,0,0,0.6)' } }>
<Picker
data={ data }
locale="it"
onEmojiSelect={ (emoji: { native: string }) =>
{
setSelectedIcon(emoji.native); setShowIconPicker(false);
} }
theme="dark"
previewPosition="none"
skinTonePosition="search"
perLine={ 8 }
maxFrequentRows={ 2 }
emojiSize={ 22 }
emojiButtonSize={ 30 }
dynamicWidth={ false }
set="native"
/>
</div>
</>
) }
{ /* Effect Selector */ }
<div className="flex flex-col gap-1">
<label className="text-[11px] font-bold uppercase tracking-wider opacity-60">{ LocalizeText('catalog.prefix.effect') }</label>
<div className="flex flex-wrap gap-1">
{ PRESET_PREFIX_EFFECTS.map(fx => (
<button
key={ fx.id }
className="px-2 py-1 rounded-md text-[11px] font-semibold transition-all"
style={ {
background: selectedEffect === fx.id ? 'rgba(59,130,246,0.25)' : 'rgba(0,0,0,0.1)',
border: selectedEffect === fx.id ? '1px solid rgba(59,130,246,0.4)' : '1px solid rgba(0,0,0,0.1)',
opacity: selectedEffect === fx.id ? 1 : 0.7
} }
onClick={ () => setSelectedEffect(fx.id) }>
<span className="mr-0.5">{ fx.icon }</span> { fx.label }
</button>
)) }
</div>
</div>
{ /* Color Mode Toggle */ }
<div className="flex flex-col gap-1">
<label className="text-[11px] font-bold uppercase tracking-wider opacity-60">{ LocalizeText('catalog.prefix.color') }</label>
<div className="flex rounded-md overflow-hidden" style={ { border: '1px solid rgba(0,0,0,0.15)' } }>
<button
className="flex-1 px-2 py-1.5 text-xs font-bold transition-all"
style={ {
background: colorMode === 'single' ? 'rgba(59,130,246,0.25)' : 'rgba(0,0,0,0.1)',
borderRight: '1px solid rgba(0,0,0,0.1)',
opacity: colorMode === 'single' ? 1 : 0.6
} }
onClick={ () =>
{
setColorMode('single'); setSelectedLetterIndex(null);
} }>
{ LocalizeText('catalog.prefix.color.single') }
</button>
<button
className="flex-1 px-2 py-1.5 text-xs font-bold transition-all"
style={ {
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);
} }>
{ LocalizeText('catalog.prefix.color.per.letter') }
</button>
</div>
</div>
{ /* Per-Letter Selector */ }
{ colorMode === 'perLetter' && prefixText.length > 0 && (
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<span className="text-[10px] opacity-50">
{ LocalizeText('catalog.prefix.color.hint') }
</span>
<button
className="text-[10px] px-1.5 py-0.5 rounded transition-all"
style={ {
background: 'rgba(0,0,0,0.1)',
border: '1px solid rgba(0,0,0,0.1)'
} }
title={ LocalizeText('catalog.prefix.color.apply.all.title') }
onClick={ applyColorToAll }>
{ LocalizeText('catalog.prefix.color.apply.all') }
</button>
</div>
<div className="flex flex-wrap gap-1 p-2 rounded-lg"
style={ {
background: 'rgba(0,0,0,0.12)',
border: '1px solid rgba(0,0,0,0.1)'
} }>
{ [ ...prefixText ].map((char, i) =>
{
const charColor = letterColors[i] || singleColor;
const isSelected = selectedLetterIndex === i;
return (
<div
key={ i }
className="relative flex items-center justify-center cursor-pointer transition-all"
style={ {
width: '28px',
height: '34px',
borderRadius: '6px',
background: isSelected
? 'rgba(59,130,246,0.2)'
: 'rgba(0,0,0,0.12)',
border: isSelected
? '2px solid rgba(59,130,246,0.6)'
: '1px solid rgba(0,0,0,0.08)',
transform: isSelected ? 'scale(1.15)' : 'scale(1)',
zIndex: isSelected ? 10 : 1,
boxShadow: isSelected ? '0 0 8px rgba(59,130,246,0.3)' : 'none'
} }
onClick={ () =>
{
setSelectedLetterIndex(i); setCustomColorInput(charColor);
} }>
<span className="text-sm font-black" style={ { color: charColor } }>
{ char }
</span>
<div
className="absolute bottom-0.5 left-1/2 -translate-x-1/2 rounded-full"
style={ {
width: '14px',
height: '3px',
backgroundColor: charColor,
boxShadow: `0 0 4px ${ charColor }`
} } />
</div>
);
}) }
</div>
</div>
) }
{ /* Color Palette */ }
<div className="flex flex-col gap-1">
{ colorMode === 'perLetter' && selectedLetterIndex !== null &&
<span className="text-[10px] opacity-50 italic">
{ LocalizeText('catalog.prefix.color.selected') } &quot;{ prefixText[selectedLetterIndex] || '' }&quot;
</span>
}
<div className="grid gap-1" style={ { gridTemplateColumns: 'repeat(auto-fill, minmax(34px, 1fr))' } }>
{ PRESET_COLORS.map((color, idx) =>
{
const isActive = currentActiveColor === color;
return (
<div
key={ idx }
className={ `aspect-square rounded cursor-pointer transition-all duration-100 border-2 ${ isActive ? 'scale-110 border-white shadow-lg' : 'border-transparent hover:scale-105' }` }
style={ {
backgroundColor: color,
boxShadow: isActive ? `0 0 8px ${ color }, 0 0 0 1px rgba(0,0,0,0.3)` : 'inset 0 1px 0 rgba(255,255,255,0.25), 0 1px 2px rgba(0,0,0,0.15)',
zIndex: isActive ? 5 : 1
} }
onClick={ () => handleColorSelect(color) } />
);
}) }
</div>
<div className="flex items-center gap-2 mt-0.5">
<label
className="relative cursor-pointer"
style={ {
width: '24px',
height: '24px',
borderRadius: '6px',
backgroundColor: customColorInput,
border: '2px solid rgba(0,0,0,0.2)',
boxShadow: `0 0 6px ${ customColorInput }40, inset 0 1px 0 rgba(255,255,255,0.3)`
} }>
<input
className="absolute inset-0 opacity-0 cursor-pointer"
style={ { width: '100%', height: '100%' } }
type="color"
value={ customColorInput }
onChange={ e => handleColorSelect(e.target.value) } />
</label>
<input
className="flex-1 px-2 py-0.5 text-xs font-mono focus:outline-none transition-all"
maxLength={ 7 }
placeholder="#FFFFFF"
style={ {
background: 'rgba(0,0,0,0.15)',
border: '1px solid rgba(0,0,0,0.1)',
color: 'inherit',
maxWidth: '80px',
borderRadius: '5px'
} }
type="text"
value={ customColorInput }
onChange={ e => handleCustomColorChange(e.target.value) } />
</div>
</div>
{ /* Purchase Footer */ }
<div className="flex items-center justify-between mt-auto pt-2"
style={ { borderTop: '1px solid rgba(0,0,0,0.1)' } }>
<div className="flex items-center gap-1">
<span className="text-xs opacity-60">{ LocalizeText('catalog.prefix.price') }</span>
<span className="text-sm font-bold">{ LocalizeText('catalog.prefix.price.amount') }</span>
</div>
<button
className="px-5 py-1.5 rounded-md text-sm font-bold transition-all"
disabled={ !isValid || purchased }
style={ {
background: !isValid
? 'rgba(0,0,0,0.1)'
: purchased
? 'linear-gradient(135deg, #22c55e, #16a34a)'
: 'linear-gradient(135deg, #3b82f6, #2563eb)',
color: !isValid ? 'rgba(0,0,0,0.3)' : '#fff',
cursor: !isValid ? 'not-allowed' : 'pointer',
border: !isValid ? '1px solid rgba(0,0,0,0.1)' : 'none',
boxShadow: isValid && !purchased ? '0 2px 8px rgba(59,130,246,0.3)' : 'none',
borderRadius: '6px'
} }
onClick={ handlePurchase }>
{ purchased ? LocalizeText('catalog.prefix.purchased') : LocalizeText('catalog.prefix.purchase') }
</button>
</div>
</div>
);
};
@@ -0,0 +1,127 @@
import { FC } from 'react';
import { FaEdit, FaExchangeAlt, FaPlus, FaSyncAlt } from 'react-icons/fa';
import { GetConfigurationValue, LocalizeText, ProductTypeEnum, SanitizeHtml } from '../../../../../api';
import { Text } from '../../../../../common';
import { useCatalogData } from '../../../../../hooks';
import { useCatalogAdmin } from '../../../CatalogAdminContext';
import { CatalogHeaderView } from '../../catalog-header/CatalogHeaderView';
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView';
import { CatalogLimitedItemWidgetView } from '../widgets/CatalogLimitedItemWidgetView';
import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView';
import { CatalogSpinnerWidgetView } from '../widgets/CatalogSpinnerWidgetView';
import { CatalogTotalPriceWidget } from '../widgets/CatalogTotalPriceWidget';
import { CatalogViewProductWidgetView } from '../widgets/CatalogViewProductWidgetView';
import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayoutDefaultView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const { currentOffer = null, currentPage = null, roomPreviewer = null } = useCatalogData();
const catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
const offerName = currentOffer?.localizationName?.replace(/\s*\([^)]*\)\s*$/g, '');
return (
<div className="nitro-catalog-classic-default-layout flex flex-col h-full gap-2">
{ adminMode && !catalogAdmin.editingPageData &&
<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);
} }
>
<FaEdit className="text-[10px]" /> { LocalizeText('catalog.admin.edit.page') }
</button>
<button
className="flex items-center gap-1 text-[10px] text-success hover:text-green-800 transition-colors cursor-pointer"
onClick={ () => catalogAdmin.setEditingOffer({ offerId: -1, product: { productClassId: 0, productType: 'i', productCount: 1, extraParam: '' } } as any) }
>
<FaPlus className="text-[10px]" /> { LocalizeText('catalog.admin.offer.new') }
</button>
{ currentOffer &&
<button
className="flex items-center gap-1 text-[10px] text-primary hover:text-dark transition-colors cursor-pointer"
title={ `${ LocalizeText('catalog.admin.offer.edit') } - Class ${ currentOffer.product.productClassId } / Offer ${ currentOffer.offerId }` }
onClick={ () => catalogAdmin.setEditingOffer(currentOffer) }
>
<FaEdit className="text-[10px]" /> { LocalizeText('catalog.admin.offer.edit') }
<span className="font-mono text-[9px] text-dark font-semibold">#{ currentOffer.product.productClassId }/{ currentOffer.offerId }</span>
</button> }
</div> }
<div className="nitro-catalog-classic-product-view">
{ currentOffer &&
<div className="nitro-catalog-classic-offer-panel flex gap-0">
<div className="nitro-catalog-classic-offer-preview relative flex items-center justify-center">
<Text className="nitro-catalog-classic-preview-title">{ offerName }</Text>
{ (currentOffer.product.productType !== ProductTypeEnum.BADGE) &&
<>
<button className="nitro-catalog-classic-preview-btn nitro-catalog-classic-preview-rotate" onClick={ () => roomPreviewer?.changeRoomObjectDirection() }>
<FaSyncAlt />
</button>
<button className="nitro-catalog-classic-preview-btn nitro-catalog-classic-preview-state" onClick={ () => roomPreviewer?.changeRoomObjectState() }>
<FaExchangeAlt />
</button>
<CatalogViewProductWidgetView />
<CatalogAddOnBadgeWidgetView className="bg-muted rounded bottom-1 right-1 absolute" />
</> }
{ (currentOffer.product.productType === ProductTypeEnum.BADGE) &&
<CatalogAddOnBadgeWidgetView className="scale-2" /> }
</div>
<div className="nitro-catalog-classic-offer-info flex flex-col flex-1 min-w-0 gap-2">
<div>
<div className="flex items-start justify-between gap-2">
<Text className="text-[13px]! font-bold text-dark leading-tight">{ offerName }</Text>
{ adminMode &&
<FaEdit
className="text-primary text-[11px] cursor-pointer hover:text-dark transition-colors shrink-0 mt-0.5"
title={ LocalizeText('catalog.admin.offer.edit') }
onClick={ () => catalogAdmin.setEditingOffer(currentOffer) }
/> }
</div>
{ adminMode &&
<div className="flex items-center gap-1 mt-1 flex-wrap">
<span className="text-[8px] font-mono text-white bg-gray-600 px-1 py-px rounded">ID: { currentOffer.product.productClassId }</span>
<span className="text-[8px] font-mono text-white bg-primary px-1 py-px rounded">Offer: { currentOffer.offerId }</span>
<span className="text-[8px] font-mono text-white bg-secondary px-1 py-px rounded">{ currentOffer.product.productType.toUpperCase() }</span>
</div> }
<CatalogLimitedItemWidgetView />
</div>
</div>
</div> }
{ !currentOffer &&
<div className="nitro-catalog-classic-welcome flex items-center gap-3">
{ !!page.localization.getImage(1) &&
<img className="w-[70px] h-[70px] object-contain rounded shrink-0" src={ page.localization.getImage(1) } /> }
<Text className="text-[11px]! text-muted" dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } />
</div> }
</div>
<div className="nitro-catalog-classic-grid-shell flex-1 overflow-auto min-h-0">
{ GetConfigurationValue('catalog.headers') &&
<CatalogHeaderView imageUrl={ currentPage.localization.getImage(0) } /> }
<CatalogItemGridWidgetView className="nitro-catalog-classic-grid" columnCount={ 6 } columnMinHeight={ 80 } columnMinWidth={ 55 } />
</div>
{ currentOffer &&
<div className="nitro-catalog-classic-price-row flex items-center justify-between gap-2">
<div className="nitro-catalog-classic-spinner-slot">
<CatalogSpinnerWidgetView />
</div>
<div className="nitro-catalog-classic-total-price-slot">
<CatalogTotalPriceWidget />
</div>
</div> }
{ currentOffer &&
<div className="nitro-catalog-classic-purchase-row flex items-start justify-end">
<div className="nitro-catalog-classic-offer-actions flex gap-1.5">
<CatalogPurchaseWidgetView />
</div>
</div> }
</div>
);
};
@@ -0,0 +1,49 @@
import { FC } from 'react';
import { SanitizeHtml } from '../../../../../api';
import { Column, Grid, Text } from '../../../../../common';
import { useCatalogData } from '../../../../../hooks';
import { CatalogGuildBadgeWidgetView } from '../widgets/CatalogGuildBadgeWidgetView';
import { CatalogGuildSelectorWidgetView } from '../widgets/CatalogGuildSelectorWidgetView';
import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView';
import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView';
import { CatalogTotalPriceWidget } from '../widgets/CatalogTotalPriceWidget';
import { CatalogViewProductWidgetView } from '../widgets/CatalogViewProductWidgetView';
import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayouGuildCustomFurniView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const { currentOffer = null } = useCatalogData();
return (
<Grid>
<Column overflow="hidden" size={ 7 }>
<CatalogItemGridWidgetView />
</Column>
<Column center={ !currentOffer } overflow="hidden" size={ 5 }>
{ !currentOffer &&
<>
{ !!page.localization.getImage(1) && <img alt="" src={ page.localization.getImage(1) } /> }
<Text center dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } />
</> }
{ currentOffer &&
<>
<div className="relative overflow-hidden">
<CatalogViewProductWidgetView />
<CatalogGuildBadgeWidgetView className="bottom-1 inset-e-1" position="absolute" />
</div>
<Column grow gap={ 1 }>
<Text truncate>{ currentOffer.localizationName }</Text>
<div className="grow!">
<CatalogGuildSelectorWidgetView />
</div>
<div className="flex justify-end">
<CatalogTotalPriceWidget alignItems="end" />
</div>
<CatalogPurchaseWidgetView />
</Column>
</> }
</Column>
</Grid>
);
};
@@ -0,0 +1,44 @@
import { FC, useState } from 'react';
import { SanitizeHtml } from '../../../../../api';
import { Column, Grid, Text } from '../../../../../common';
import { useCatalogData, useCatalogUiState, useUserGroups } from '../../../../../hooks';
import { CatalogFirstProductSelectorWidgetView } from '../widgets/CatalogFirstProductSelectorWidgetView';
import { CatalogGuildSelectorWidgetView } from '../widgets/CatalogGuildSelectorWidgetView';
import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView';
import { CatalogTotalPriceWidget } from '../widgets/CatalogTotalPriceWidget';
import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayouGuildForumView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const [ selectedGroupIndex, setSelectedGroupIndex ] = useState<number>(0);
const { currentOffer = null } = useCatalogData();
const { setCurrentOffer = null } = useCatalogUiState();
const { data: groups = null } = useUserGroups();
return (
<>
<CatalogFirstProductSelectorWidgetView />
<Grid>
<Column className="bg-muted rounded p-2 text-black" overflow="hidden" size={ 7 }>
<div className="overflow-auto" dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(1)) } } />
</Column>
<Column gap={ 1 } overflow="hidden" size={ 5 }>
{ !!currentOffer &&
<>
<Column grow gap={ 1 }>
<Text truncate>{ currentOffer.localizationName }</Text>
<div className="grow!">
<CatalogGuildSelectorWidgetView />
</div>
<div className="flex justify-end">
<CatalogTotalPriceWidget alignItems="end" />
</div>
<CatalogPurchaseWidgetView noGiftOption={ true } />
</Column>
</> }
</Column>
</Grid>
</>
);
};
@@ -0,0 +1,29 @@
import { CreateLinkEvent } from '@nitrots/nitro-renderer';
import { FC } from 'react';
import { LocalizeText, SanitizeHtml } from '../../../../../api';
import { Button } from '../../../../../common/Button';
import { Column } from '../../../../../common/Column';
import { Grid } from '../../../../../common/Grid';
import { LayoutImage } from '../../../../../common/layout/LayoutImage';
import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayouGuildFrontpageView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
return (
<Grid>
<Column className="bg-muted rounded p-2 text-black" overflow="hidden" size={ 7 }>
<div dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(2)) } } />
<div className="overflow-auto" dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } />
<div dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(1)) } } />
</Column>
<Column center overflow="hidden" size={ 5 }>
<LayoutImage imageUrl={ page.localization.getImage(1) } />
<Button onClick={ () => CreateLinkEvent('groups/create') }>
{ LocalizeText('catalog.start.guild.purchase.button') }
</Button>
</Column>
</Grid>
);
};
@@ -0,0 +1,16 @@
import { FC } from 'react';
import { SanitizeHtml } from '../../../../../api';
import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayoutInfoLoyaltyView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
return (
<div className="h-full nitro-catalog-layout-info-loyalty text-black flex flex-row">
<div className="overflow-auto h-full flex flex-col info-loyalty-content">
<div dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } />
</div>
</div>
);
};
@@ -0,0 +1,8 @@
import { FC } from 'react';
import { CatalogLayoutProps } from './CatalogLayout.types';
import { CatalogLayoutPets3View } from './CatalogLayoutPets3View';
export const CatalogLayoutPets2View: FC<CatalogLayoutProps> = props =>
{
return <CatalogLayoutPets3View { ...props } />;
};
@@ -0,0 +1,37 @@
import { FC } from 'react';
import { FaPaw } from 'react-icons/fa';
import { SanitizeHtml } from '../../../../../api';
import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayoutPets3View: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const imageUrl = page.localization.getImage(1);
return (
<div className="flex flex-col h-full gap-2">
{ /* Header card */ }
<div className="flex items-center gap-3 p-2.5 bg-white rounded border-2 border-card-grid-item-border">
{ imageUrl && <img alt="" className="w-[60px] h-[60px] object-contain shrink-0" src={ imageUrl } /> }
<div>
<div className="flex items-center gap-1.5 mb-0.5">
<FaPaw className="text-primary text-xs" />
<span className="text-sm font-bold" dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(1)) } } />
</div>
</div>
</div>
{ /* Content */ }
<div className="flex-1 overflow-auto bg-white rounded border-2 border-card-grid-item-border p-3">
<div className="text-[11px] leading-relaxed" dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(2)) } } />
</div>
{ /* Footer */ }
{ !!page.localization.getText(3) &&
<div className="p-2 bg-card-grid-item rounded border border-card-grid-item-border">
<span className="text-[11px] font-bold" dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(3)) } } />
</div> }
</div>
);
};
@@ -0,0 +1,120 @@
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 { useCatalogUiState, useNavigatorData, useRoomPromote } from '../../../../../hooks';
import { NitroInput } from '../../../../../layout';
import { CatalogLayoutProps } from './CatalogLayout.types';
let isPurchasingAd = false;
export const CatalogLayoutRoomAdsView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const [ eventName, setEventName ] = useState<string>('');
const [ eventDesc, setEventDesc ] = useState<string>('');
const [ roomId, setRoomId ] = useState<number>(-1);
const [ extended, setExtended ] = useState<boolean>(false);
const [ categoryId, setCategoryId ] = useState<number>(1);
const { categories } = useNavigatorData();
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)
{
setRoomId(promoteInformation.data.flatId);
setEventName(promoteInformation.data.eventName);
setEventDesc(promoteInformation.data.eventDescription);
setCategoryId(promoteInformation.data.categoryId);
setExtended(isExtended); // This is for sending to packet
setIsExtended(false); // This is from hook useRoomPromotte
}
}, [ isExtended, eventName, eventDesc, categoryId, promoteInformation.data, setIsExtended ]);
const resetData = () =>
{
setRoomId(-1);
setEventName('');
setEventDesc('');
setCategoryId(1);
setIsExtended(false);
setIsVisible(false);
};
const purchaseAd = () =>
{
if(isPurchasingAd) return;
isPurchasingAd = true;
const pageId = page.pageId;
const offerId = page.offers.length >= 1 ? page.offers[0].offerId : -1;
const flatId = roomId;
const name = eventName;
const desc = eventDesc;
const catId = categoryId;
SendMessageComposer(new PurchaseRoomAdMessageComposer(pageId, offerId, flatId, name, extended, desc, catId));
resetData();
};
useEffect(() =>
{
// TODO: someone needs to fix this for morningstar
SendMessageComposer(new GetUserEventCatsMessageComposer());
}, []);
return (<>
<Text bold center>{ LocalizeText('roomad.catalog_header') }</Text>
<Column className="text-black" overflow="hidden" size={ 12 }>
<div>{ LocalizeText('roomad.catalog_text', [ 'duration' ], [ '120' ]) }</div>
<div className="p-1 rounded bg-muted">
<Column gap={ 2 }>
<Text bold>{ LocalizeText('navigator.category') }</Text>
<select className="form-select form-select-sm" disabled={ extended } value={ categoryId } onChange={ event => setCategoryId(parseInt(event.target.value)) }>
{ categories && categories.map((cat, index) => <option key={ index } value={ cat.id }>{ LocalizeText(cat.name) }</option>) }
</select>
</Column>
<div className="flex flex-col gap-1">
<Text bold>{ LocalizeText('roomad.catalog_name') }</Text>
<NitroInput maxLength={ 64 } readOnly={ extended } value={ eventName } onChange={ event => setEventName(event.target.value) } />
</div>
<div className="flex flex-col gap-1">
<Text bold>{ LocalizeText('roomad.catalog_description') }</Text>
<textarea className="min-h-[calc(1.5em+ .5rem+2px)] px-[.5rem] py-[.25rem] rounded-[.2rem] form-control-sm" maxLength={ 64 } readOnly={ extended } value={ eventDesc } onChange={ event => setEventDesc(event.target.value) } />
</div>
<div className="flex flex-col gap-1">
<Text bold>{ LocalizeText('roomad.catalog_roomname') }</Text>
<select className="form-select form-select-sm" disabled={ extended } value={ roomId } onChange={ event => setRoomId(parseInt(event.target.value)) }>
<option disabled value={ -1 }>{ LocalizeText('roomad.catalog_roomname') }</option>
{ availableRooms && availableRooms.map((room, index) => <option key={ index } value={ room.roomId }>{ room.roomName }</option>) }
</select>
</div>
<div className="flex flex-col gap-1">
<Button disabled={ (!eventName || !eventDesc || roomId === -1) } variant={ (!eventName || !eventDesc || roomId === -1) ? 'danger' : 'success' } onClick={ purchaseAd }>{ extended ? LocalizeText('roomad.extend.event') : LocalizeText('buy') }</Button>
</div>
</div>
</Column>
</>
);
};
interface INavigatorCategory
{
id: number;
name: string;
visible: boolean;
}
@@ -0,0 +1,42 @@
import { FC } from 'react';
import { SanitizeHtml } from '../../../../../api';
import { Column, Grid, Text } from '../../../../../common';
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
import { CatalogBundleGridWidgetView } from '../widgets/CatalogBundleGridWidgetView';
import { CatalogFirstProductSelectorWidgetView } from '../widgets/CatalogFirstProductSelectorWidgetView';
import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView';
import { CatalogSimplePriceWidgetView } from '../widgets/CatalogSimplePriceWidgetView';
import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayoutRoomBundleView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
return (
<>
<CatalogFirstProductSelectorWidgetView />
<Grid>
<Column overflow="hidden" size={ 7 }>
{ !!page.localization.getText(2) &&
<Text dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(2)) } } /> }
<Column grow className="bg-muted p-2 rounded" overflow="hidden">
<CatalogBundleGridWidgetView fullWidth className="nitro-catalog-layout-bundle-grid" />
</Column>
</Column>
<Column gap={ 1 } overflow="hidden" size={ 5 }>
{ !!page.localization.getText(1) &&
<Text center small overflow="auto">{ page.localization.getText(1) }</Text> }
<Column grow gap={ 0 } overflow="hidden" position="relative">
{ !!page.localization.getImage(1) &&
<img alt="" className="grow!" src={ page.localization.getImage(1) } /> }
<CatalogAddOnBadgeWidgetView className="bg-muted rounded bottom-0 inset-s-0" position="absolute" />
<CatalogSimplePriceWidgetView />
</Column>
<div className="flex flex-col gap-1">
<CatalogPurchaseWidgetView />
</div>
</Column>
</Grid>
</>
);
};
@@ -0,0 +1,42 @@
import { FC } from 'react';
import { SanitizeHtml } from '../../../../../api';
import { Column, Grid, Text } from '../../../../../common';
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
import { CatalogBundleGridWidgetView } from '../widgets/CatalogBundleGridWidgetView';
import { CatalogFirstProductSelectorWidgetView } from '../widgets/CatalogFirstProductSelectorWidgetView';
import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView';
import { CatalogSimplePriceWidgetView } from '../widgets/CatalogSimplePriceWidgetView';
import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayoutSingleBundleView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
return (
<>
<CatalogFirstProductSelectorWidgetView />
<Grid>
<Column overflow="hidden" size={ 7 }>
{ !!page.localization.getText(2) &&
<Text dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(2)) } } /> }
<Column grow className="bg-muted p-2 rounded" overflow="hidden">
<CatalogBundleGridWidgetView fullWidth className="nitro-catalog-layout-bundle-grid" />
</Column>
</Column>
<Column gap={ 1 } overflow="hidden" size={ 5 }>
{ !!page.localization.getText(1) &&
<Text center small overflow="auto">{ page.localization.getText(1) }</Text> }
<Column grow gap={ 0 } overflow="hidden" position="relative">
{ !!page.localization.getImage(1) &&
<img alt="" className="grow!" src={ page.localization.getImage(1) } /> }
<CatalogAddOnBadgeWidgetView className="bg-muted rounded bottom-0 inset-s-0" position="absolute" />
<CatalogSimplePriceWidgetView />
</Column>
<div className="flex flex-col gap-1">
<CatalogPurchaseWidgetView />
</div>
</Column>
</Grid>
</>
);
};
@@ -0,0 +1,113 @@
import { GetOfficialSongIdMessageComposer, GetSoundManager, MusicPriorities, OfficialSongIdMessageEvent } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { GetConfigurationValue, LocalizeText, ProductTypeEnum, SanitizeHtml, SendMessageComposer } from '../../../../../api';
import { Button, Column, Grid, LayoutImage, Text } from '../../../../../common';
import { useCatalogData, useMessageEvent } from '../../../../../hooks';
import { CatalogHeaderView } from '../../catalog-header/CatalogHeaderView';
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView';
import { CatalogLimitedItemWidgetView } from '../widgets/CatalogLimitedItemWidgetView';
import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView';
import { CatalogSpinnerWidgetView } from '../widgets/CatalogSpinnerWidgetView';
import { CatalogTotalPriceWidget } from '../widgets/CatalogTotalPriceWidget';
import { CatalogViewProductWidgetView } from '../widgets/CatalogViewProductWidgetView';
import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayoutSoundMachineView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const [ songId, setSongId ] = useState(-1);
const [ officialSongId, setOfficialSongId ] = useState('');
const { currentOffer = null, currentPage = null } = useCatalogData();
const previewSong = (previewSongId: number) => GetSoundManager().musicController?.playSong(previewSongId, MusicPriorities.PRIORITY_PURCHASE_PREVIEW, 15, 0, 0, 0);
useMessageEvent<OfficialSongIdMessageEvent>(OfficialSongIdMessageEvent, event =>
{
const parser = event.getParser();
if(parser.officialSongId !== officialSongId) return;
setSongId(parser.songId);
});
useEffect(() =>
{
if(!currentOffer) return;
const product = currentOffer.product;
if(!product) return;
if(product.extraParam.length > 0)
{
const id = parseInt(product.extraParam);
if(id > 0)
{
setSongId(id);
}
else
{
setOfficialSongId(product.extraParam);
SendMessageComposer(new GetOfficialSongIdMessageComposer(product.extraParam));
}
}
else
{
setOfficialSongId('');
setSongId(-1);
}
return () => GetSoundManager().musicController?.stop(MusicPriorities.PRIORITY_PURCHASE_PREVIEW);
}, [ currentOffer ]);
useEffect(() =>
{
return () => GetSoundManager().musicController?.stop(MusicPriorities.PRIORITY_PURCHASE_PREVIEW);
}, []);
return (
<>
<Grid>
<Column overflow="hidden" size={ 7 }>
{ GetConfigurationValue('catalog.headers') &&
<CatalogHeaderView imageUrl={ currentPage.localization.getImage(0) } /> }
<CatalogItemGridWidgetView />
</Column>
<Column center={ !currentOffer } overflow="hidden" size={ 5 }>
{ !currentOffer &&
<>
{ !!page.localization.getImage(1) &&
<LayoutImage imageUrl={ page.localization.getImage(1) } /> }
<Text center dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } />
</> }
{ currentOffer &&
<>
<div className="flex items-center justify-center overflow-hidden" style={ { height: 140 } }>
{ (currentOffer.product.productType !== ProductTypeEnum.BADGE) &&
<>
<CatalogViewProductWidgetView />
<CatalogAddOnBadgeWidgetView className="bg-muted rounded bottom-1 inset-e-1" />
</> }
{ (currentOffer.product.productType === ProductTypeEnum.BADGE) && <CatalogAddOnBadgeWidgetView className="scale-2" /> }
</div>
<Column grow gap={ 1 }>
<CatalogLimitedItemWidgetView />
<Text grow truncate>{ currentOffer.localizationName }</Text>
{ songId > -1 && <Button onClick={ () => previewSong(songId) }>{ LocalizeText('play_preview_button') }</Button>
}
<div className="flex justify-between">
<div className="flex flex-col gap-1">
<CatalogSpinnerWidgetView />
</div>
<CatalogTotalPriceWidget alignItems="end" justifyContent="end" />
</div>
<CatalogPurchaseWidgetView />
</Column>
</> }
</Column>
</Grid>
</>
);
};
@@ -0,0 +1,48 @@
import { FC, useEffect } from 'react';
import { SanitizeHtml } from '../../../../../api';
import { Column, Grid, Text } from '../../../../../common';
import { useCatalogData } from '../../../../../hooks';
import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView';
import { CatalogSpacesWidgetView } from '../widgets/CatalogSpacesWidgetView';
import { CatalogTotalPriceWidget } from '../widgets/CatalogTotalPriceWidget';
import { CatalogViewProductWidgetView } from '../widgets/CatalogViewProductWidgetView';
import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayoutSpacesView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const { currentOffer = null, roomPreviewer = null } = useCatalogData();
useEffect(() =>
{
roomPreviewer.updatePreviewObjectBoundingRectangle();
}, [ roomPreviewer ]);
return (
<Grid>
<Column overflow="hidden" size={ 7 }>
<CatalogSpacesWidgetView />
</Column>
<Column center={ !currentOffer } overflow="hidden" size={ 5 }>
{ !currentOffer &&
<>
{ !!page.localization.getImage(1) && <img alt="" src={ page.localization.getImage(1) } /> }
<Text center dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } />
</> }
{ currentOffer &&
<>
<div className="relative overflow-hidden">
<CatalogViewProductWidgetView />
</div>
<Column grow gap={ 1 }>
<Text grow truncate>{ currentOffer.localizationName }</Text>
<div className="flex justify-end">
<CatalogTotalPriceWidget alignItems="end" />
</div>
<CatalogPurchaseWidgetView />
</Column>
</> }
</Column>
</Grid>
);
};
@@ -0,0 +1,142 @@
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 { useCatalogData, useCatalogUiState } from '../../../../../hooks';
import { useCatalogAdmin } from '../../../CatalogAdminContext';
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView';
import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView';
import { CatalogTotalPriceWidget } from '../widgets/CatalogTotalPriceWidget';
import { CatalogViewProductWidgetView } from '../widgets/CatalogViewProductWidgetView';
import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayoutTrophiesView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const [ trophyText, setTrophyText ] = useState<string>('');
const { currentOffer = null } = useCatalogData();
const { setPurchaseOptions = null } = useCatalogUiState();
const catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
useEffect(() =>
{
if(!currentOffer) return;
setPurchaseOptions(prevValue =>
{
const newValue = { ...prevValue };
newValue.extraData = trophyText;
return newValue;
});
}, [ currentOffer, trophyText, setPurchaseOptions ]);
const canPurchase = currentOffer && trophyText.trim().length > 0;
return (
<div className="flex flex-col h-full gap-2">
{ /* Admin: quick actions */ }
{ adminMode && !catalogAdmin.editingPageData &&
<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);
} }
>
<FaEdit className="text-[10px]" /> { LocalizeText('catalog.admin.edit.page') }
</button>
<button
className="flex items-center gap-1 text-[10px] text-success hover:text-green-800 transition-colors cursor-pointer"
onClick={ () => catalogAdmin.setEditingOffer({ offerId: -1, product: { productClassId: 0, productType: 'i', productCount: 1, extraParam: '' } } as any) }
>
<FaPlus className="text-[10px]" /> { LocalizeText('catalog.admin.offer.new') }
</button>
</div> }
{ /* Selected trophy card. shrink-0 + no overflow-hidden so the
Buy button stays inside the panel even when the grid below
holds many trophies. */ }
{ currentOffer
? <div className="flex gap-0 bg-white rounded border-2 border-warning/40 shrink-0" style={ { boxShadow: '0 0 8px rgba(255,193,7,0.15)' } }>
{ /* Preview */ }
<div className="w-[120px] min-w-[120px] relative flex items-center justify-center border-r-2 border-warning/30" style={ { background: 'linear-gradient(180deg, #fff9e6 0%, #fff3cc 100%)' } }>
{ (currentOffer.product.productType !== ProductTypeEnum.BADGE)
? <>
<CatalogViewProductWidgetView />
<CatalogAddOnBadgeWidgetView className="bg-muted rounded bottom-1 right-1 absolute" />
</>
: <CatalogAddOnBadgeWidgetView className="scale-2" /> }
</div>
{ /* Info */ }
<div className="flex flex-col flex-1 min-w-0 p-2 gap-1.5">
<div className="flex items-center gap-1.5">
<FaTrophy className="text-warning text-[11px]" />
<Text className="text-[12px]! font-bold text-dark leading-tight">{ currentOffer.localizationName }</Text>
{ adminMode &&
<FaEdit
className="text-primary text-[11px] cursor-pointer hover:text-dark transition-colors shrink-0"
title={ LocalizeText('catalog.admin.offer.edit') }
onClick={ () => catalogAdmin.setEditingOffer(currentOffer) }
/> }
</div>
{ adminMode &&
<div className="flex items-center gap-1 flex-wrap">
<span className="text-[8px] font-mono text-white bg-gray-600 px-1 py-px rounded">ID: { currentOffer.product.productClassId }</span>
<span className="text-[8px] font-mono text-white bg-primary px-1 py-px rounded">Offer: { currentOffer.offerId }</span>
</div> }
<CatalogTotalPriceWidget />
{ !canPurchase &&
<span className="text-[9px] text-warning italic">{ LocalizeText('catalog.trophies.write.hint') }</span> }
<div className="flex gap-1.5">
<CatalogPurchaseWidgetView />
</div>
</div>
</div>
: <div className="flex items-start gap-3 p-2.5 bg-white rounded border-2 border-card-grid-item-border">
{ !!page.localization.getImage(1) &&
<img className="w-[50px] h-[50px] object-contain rounded shrink-0 mt-0.5" src={ page.localization.getImage(1) } /> }
<div className="min-w-0">
<div className="flex items-center gap-1.5 mb-1">
<FaTrophy className="text-warning text-[11px]" />
<span className="text-[12px] font-bold">{ LocalizeText('catalog.trophies.title') }</span>
</div>
<Text className="text-[10px]! text-muted leading-relaxed" dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } />
</div>
</div> }
{ /* Trophy inscription */ }
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1.5">
<FaPen className="text-[8px] text-warning" />
<span className="text-[9px] font-bold text-muted uppercase tracking-wider">{ LocalizeText('catalog.trophies.inscription') }</span>
<span className={ `text-[9px] ml-auto ${ trophyText.length > 180 ? 'text-danger font-bold' : 'text-muted' }` }>{ trophyText.length }/200</span>
</div>
<div className="relative">
<textarea
className="w-full h-[60px] text-[11px] rounded p-2 pr-3 resize-none focus:outline-none transition-all border-2"
maxLength={ 200 }
placeholder={ LocalizeText('catalog.trophies.inscription.placeholder') }
style={ {
background: trophyText.length > 0 ? 'linear-gradient(180deg, #fffdf5 0%, #fff8e8 100%)' : '#fff',
borderColor: trophyText.length > 0 ? 'rgba(255,193,7,0.4)' : undefined
} }
value={ trophyText }
onChange={ event => setTrophyText(event.target.value) }
/>
{ trophyText.length > 0 &&
<FaTrophy className="absolute top-2 right-2 text-[10px] text-warning/30" /> }
</div>
</div>
{ /* Trophy grid */ }
<div className="flex-1 overflow-auto min-h-0">
<CatalogItemGridWidgetView columnCount={ 7 } columnMinHeight={ 50 } columnMinWidth={ 50 } />
</div>
</div>
);
};
@@ -0,0 +1,279 @@
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, LayoutLoadingSpinnerView, Text } from '../../../../../common';
import { CatalogEvent, CatalogPurchaseFailureEvent, CatalogPurchasedEvent } from '../../../../../events';
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 [ 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 { 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) =>
{
switch(event.type)
{
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;
}
}, []);
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 = '';
if(offer.months > 0)
{
offerText = LocalizeText('catalog.vip.item.header.months', [ 'num_months' ], [ offer.months.toString() ]);
}
if(offer.extraDays > 0)
{
if(offerText !== '') offerText += ' ';
offerText += (' ' + LocalizeText('catalog.vip.item.header.days', [ 'num_days' ], [ offer.extraDays.toString() ]));
}
return offerText;
}, []);
const getPurchaseHeader = useCallback(() =>
{
if(!purse) return '';
const extensionOrSubscription = (purse.clubDays > 0 || purse.clubPeriods > 0) ? 'extension.' : 'subscription.';
const daysOrMonths = ((pendingOffer.months === 0) ? 'days' : 'months');
const daysOrMonthsText = ((pendingOffer.months === 0) ? pendingOffer.extraDays : pendingOffer.months);
const locale = LocalizeText('catalog.vip.buy.confirm.' + extensionOrSubscription + daysOrMonths);
return locale.replace('%NUM_' + daysOrMonths.toUpperCase() + '%', daysOrMonthsText.toString());
}, [ pendingOffer, purse ]);
const getPurchaseValidUntil = useCallback(() =>
{
let locale = LocalizeText('catalog.vip.buy.confirm.end_date');
locale = locale.replace('%month%', pendingOffer.month.toString());
locale = locale.replace('%day%', pendingOffer.day.toString());
locale = locale.replace('%year%', pendingOffer.year.toString());
return locale;
}, [ pendingOffer ]);
const getSubscriptionDetails = useMemo(() =>
{
const clubDays = purse.clubDays;
const clubPeriods = purse.clubPeriods;
const totalDays = (clubPeriods * 31) + clubDays;
return LocalizeText('catalog.vip.extend.info', [ 'days' ], [ totalDays.toString() ]);
}, [ purse ]);
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);
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(() =>
{
if(!pendingOffer) return null;
if(pendingOffer.priceCredits > getCurrencyAmount(-1))
{
return <Button fullWidth variant="danger">{ LocalizeText('catalog.alert.notenough.title') }</Button>;
}
if(pendingOffer.priceActivityPoints > getCurrencyAmount(pendingOffer.priceActivityPointsType))
{
return <Button fullWidth variant="danger">{ LocalizeText('catalog.alert.notenough.activitypoints.title.' + pendingOffer.priceActivityPointsType) }</Button>;
}
const giftBlocked = giftMode && (!giftRecipient.trim() || isSelfGift);
const buyLabel = giftMode ? LocalizeText('catalog.gift_wrapping.give_gift') : LocalizeText('buy');
switch(purchaseState)
{
case CatalogPurchaseState.CONFIRM:
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 disabled={ giftBlocked } fullWidth variant="success" onClick={ () => setPurchaseState(CatalogPurchaseState.CONFIRM) }>{ buyLabel }</Button>;
}
}, [ pendingOffer, purchaseState, purchaseSubscription, getCurrencyAmount, giftMode, giftRecipient, isSelfGift ]);
return (
<Grid>
<Column fullHeight justifyContent="between" overflow="hidden" size={ 7 }>
<AutoGrid className="nitro-catalog-layout-vip-buy-grid" columnCount={ 1 }>
{ offers && (offers.length > 0) && offers.map((offer, index) =>
{
const isActive = (pendingOffer === offer);
return (
<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>
<Text center dangerouslySetInnerHTML={ { __html: SanitizeHtml(LocalizeText('catalog.vip.buy.hccenter')) } }></Text>
</Column>
<Column overflow="hidden" size={ 5 }>
<Column center fullHeight overflow="hidden">
{ currentPage.localization.getImage(1) && <img alt="" src={ currentPage.localization.getImage(1) } /> }
<Text center dangerouslySetInnerHTML={ { __html: SanitizeHtml(getSubscriptionDetails) } } overflow="auto" />
</Column>
{ pendingOffer &&
<Column fullWidth grow justifyContent="end">
<Flex alignItems="end">
<Column grow gap={ 0 }>
<Text fontWeight="bold">{ giftMode ? LocalizeText('catalog.purchase_confirmation.gift') : getPurchaseHeader() }</Text>
<Text>{ getPurchaseValidUntil() }</Text>
</Column>
<div className="flex flex-col gap-1">
{ (pendingOffer.priceCredits > 0) &&
<Flex alignItems="center" gap={ 1 } justifyContent="end">
<Text>{ pendingOffer.priceCredits }</Text>
<LayoutCurrencyIcon type={ -1 } />
</Flex> }
{ (pendingOffer.priceActivityPoints > 0) &&
<Flex alignItems="center" gap={ 1 } justifyContent="end">
<Text>{ pendingOffer.priceActivityPoints }</Text>
<LayoutCurrencyIcon type={ pendingOffer.priceActivityPointsType } />
</Flex> }
</div>
</Flex>
{ 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>
</Grid>
);
};
@@ -0,0 +1,91 @@
import { ICatalogPage } from '../../../../../api';
import { CatalogLayoutProps } from './CatalogLayout.types';
import { CatalogLayoutBadgeDisplayView } from './CatalogLayoutBadgeDisplayView';
import { CatalogLayoutBcInfoView } from './CatalogLayoutBcInfoView';
import { CatalogLayoutBuildersClubBuyView } from './CatalogLayoutBuildersClubBuyView';
import { CatalogLayoutColorGroupingView } from './CatalogLayoutColorGroupingView';
import { CatalogLayoutCustomPrefixView } from './CatalogLayoutCustomPrefixView';
import { CatalogLayoutDefaultView } from './CatalogLayoutDefaultView';
import { CatalogLayouGuildCustomFurniView } from './CatalogLayoutGuildCustomFurniView';
import { CatalogLayouGuildForumView } from './CatalogLayoutGuildForumView';
import { CatalogLayouGuildFrontpageView } from './CatalogLayoutGuildFrontpageView';
import { CatalogLayoutInfoLoyaltyView } from './CatalogLayoutInfoLoyaltyView';
import { CatalogLayoutPets2View } from './CatalogLayoutPets2View';
import { CatalogLayoutPets3View } from './CatalogLayoutPets3View';
import { CatalogLayoutRoomAdsView } from './CatalogLayoutRoomAdsView';
import { CatalogLayoutRoomBundleView } from './CatalogLayoutRoomBundleView';
import { CatalogLayoutSingleBundleView } from './CatalogLayoutSingleBundleView';
import { CatalogLayoutSoundMachineView } from './CatalogLayoutSoundMachineView';
import { CatalogLayoutSpacesView } from './CatalogLayoutSpacesView';
import { CatalogLayoutTrophiesView } from './CatalogLayoutTrophiesView';
import { CatalogLayoutVipBuyView } from './CatalogLayoutVipBuyView';
import { CatalogLayoutFrontpage4View } from './frontpage4/CatalogLayoutFrontpage4View';
import { CatalogLayoutMarketplaceOwnItemsView } from './marketplace/CatalogLayoutMarketplaceOwnItemsView';
import { CatalogLayoutMarketplacePublicItemsView } from './marketplace/CatalogLayoutMarketplacePublicItemsView';
import { CatalogLayoutPetView } from './pets/CatalogLayoutPetView';
import { CatalogLayoutVipGiftsView } from './vip-gifts/CatalogLayoutVipGiftsView';
export const GetCatalogLayout = (page: ICatalogPage, hideNavigation: () => void) =>
{
if(!page) return null;
const layoutProps: CatalogLayoutProps = { page, hideNavigation };
switch(page.layoutCode)
{
case 'frontpage_featured':
return null;
case 'info_duckets':
return <CatalogLayoutBcInfoView { ...layoutProps } />;
case 'frontpage4':
return <CatalogLayoutFrontpage4View { ...layoutProps } />;
case 'pets':
return <CatalogLayoutPetView { ...layoutProps } />;
case 'pets2':
return <CatalogLayoutPets2View { ...layoutProps } />;
case 'pets3':
return <CatalogLayoutPets3View { ...layoutProps } />;
case 'vip_buy':
return <CatalogLayoutVipBuyView { ...layoutProps } />;
case 'builders_club_frontpage':
case 'builders_club_addons':
case 'builders_club_loyalty':
return <CatalogLayoutBuildersClubBuyView { ...layoutProps } />;
case 'guild_frontpage':
return <CatalogLayouGuildFrontpageView { ...layoutProps } />;
case 'guild_forum':
return <CatalogLayouGuildForumView { ...layoutProps } />;
case 'guild_custom_furni':
return <CatalogLayouGuildCustomFurniView { ...layoutProps } />;
case 'club_gifts':
return <CatalogLayoutVipGiftsView { ...layoutProps } />;
case 'marketplace_own_items':
return <CatalogLayoutMarketplaceOwnItemsView { ...layoutProps } />;
case 'marketplace':
return <CatalogLayoutMarketplacePublicItemsView { ...layoutProps } />;
case 'single_bundle':
return <CatalogLayoutSingleBundleView { ...layoutProps } />;
case 'room_bundle':
return <CatalogLayoutRoomBundleView { ...layoutProps } />;
case 'spaces_new':
return <CatalogLayoutSpacesView { ...layoutProps } />;
case 'trophies':
return <CatalogLayoutTrophiesView { ...layoutProps } />;
case 'info_loyalty':
return <CatalogLayoutInfoLoyaltyView { ...layoutProps } />;
case 'badge_display':
return <CatalogLayoutBadgeDisplayView { ...layoutProps } />;
case 'roomads':
return <CatalogLayoutRoomAdsView { ...layoutProps } />;
case 'default_3x3_color_grouping':
return <CatalogLayoutColorGroupingView { ...layoutProps } />;
case 'soundmachine':
return <CatalogLayoutSoundMachineView { ...layoutProps } />;
case 'custom_prefix':
return <CatalogLayoutCustomPrefixView { ...layoutProps } />;
case 'bots':
case 'default_3x3':
default:
return <CatalogLayoutDefaultView { ...layoutProps } />;
}
};
@@ -0,0 +1,37 @@
import { FrontPageItem } from '@nitrots/nitro-renderer';
import { FC, useMemo } from 'react';
import { GetConfigurationValue } from '../../../../../../api';
import { LayoutBackgroundImage, LayoutBackgroundImageProps } from '../../../../../../common';
import { Text } from '../../../../../../common/Text';
export interface CatalogLayoutFrontPageItemViewProps extends LayoutBackgroundImageProps
{
item: FrontPageItem;
}
export const CatalogLayoutFrontPageItemView: FC<CatalogLayoutFrontPageItemViewProps> = props =>
{
const { item = null, position = 'relative', pointer = true, overflow = 'hidden', fullHeight = true, classNames = [], children = null, ...rest } = props;
const getClassNames = useMemo(() =>
{
const newClassNames: string[] = [ 'rounded', 'nitro-front-page-item' ];
if(classNames.length) newClassNames.push(...classNames);
return newClassNames;
}, [ classNames ]);
if(!item) return null;
const imageUrl = (GetConfigurationValue<string>('image.library.url') + item.itemPromoImage);
return (
<LayoutBackgroundImage classNames={ getClassNames } fullHeight={ fullHeight } imageUrl={ imageUrl } overflow={ overflow } pointer={ pointer } position={ position } { ...rest }>
<Text className="bg-dark rounded p-2 m-2 bottom-0" position="absolute" variant="white">
{ item.itemName }
</Text>
{ children }
</LayoutBackgroundImage>
);
};
@@ -0,0 +1,49 @@
import { CreateLinkEvent, FrontPageItem } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect } from 'react';
import { Column, Grid } from '../../../../../../common';
import { useCatalogData } from '../../../../../../hooks';
import { CatalogRedeemVoucherView } from '../../common/CatalogRedeemVoucherView';
import { CatalogLayoutProps } from '../CatalogLayout.types';
import { CatalogLayoutFrontPageItemView } from './CatalogLayoutFrontPageItemView';
export const CatalogLayoutFrontpage4View: FC<CatalogLayoutProps> = props =>
{
const { page = null, hideNavigation = null } = props;
const { frontPageItems = [] } = useCatalogData();
const selectItem = useCallback((item: FrontPageItem) =>
{
switch(item.type)
{
case FrontPageItem.ITEM_CATALOGUE_PAGE:
CreateLinkEvent(`catalog/open/${ item.catalogPageLocation }`);
return;
case FrontPageItem.ITEM_PRODUCT_OFFER:
CreateLinkEvent(`catalog/open/${ item.productOfferId }`);
return;
}
}, []);
useEffect(() =>
{
hideNavigation();
}, [ page, hideNavigation ]);
return (
<Grid>
<Column size={ 4 }>
{ frontPageItems[0] &&
<CatalogLayoutFrontPageItemView item={ frontPageItems[0] } onClick={ event => selectItem(frontPageItems[0]) } /> }
</Column>
<Column size={ 8 }>
{ frontPageItems[1] &&
<CatalogLayoutFrontPageItemView item={ frontPageItems[1] } onClick={ event => selectItem(frontPageItems[1]) } /> }
{ frontPageItems[2] &&
<CatalogLayoutFrontPageItemView item={ frontPageItems[2] } onClick={ event => selectItem(frontPageItems[2]) } /> }
{ frontPageItems[3] &&
<CatalogLayoutFrontPageItemView item={ frontPageItems[3] } onClick={ event => selectItem(frontPageItems[3]) } /> }
<CatalogRedeemVoucherView text={ page.localization.getText(1) } />
</Column>
</Grid>
);
};
@@ -0,0 +1,83 @@
import { FC, useCallback, useMemo } from 'react';
import { GetImageIconUrlForProduct, LocalizeText, MarketPlaceOfferState, MarketplaceOfferData, ProductTypeEnum } from '../../../../../../api';
import { Button, Column, LayoutGridItem, Text } from '../../../../../../common';
export interface MarketplaceItemViewProps
{
offerData: MarketplaceOfferData;
type?: number;
onClick(offerData: MarketplaceOfferData): void;
}
export const OWN_OFFER = 1;
export const PUBLIC_OFFER = 2;
export const CatalogLayoutMarketplaceItemView: FC<MarketplaceItemViewProps> = props =>
{
const { offerData = null, type = PUBLIC_OFFER, onClick = null } = props;
const getMarketplaceOfferTitle = useMemo(() =>
{
if(!offerData) return '';
// desc
return LocalizeText(((offerData.furniType === 2) ? 'wallItem' : 'roomItem') + `.name.${ offerData.furniId }`);
}, [ offerData ]);
const offerTime = useCallback( () =>
{
if(!offerData) return '';
if(offerData.status === MarketPlaceOfferState.SOLD) return LocalizeText('catalog.marketplace.offer.sold');
if(offerData.timeLeftMinutes <= 0) return LocalizeText('catalog.marketplace.offer.expired');
const time = Math.max(1, offerData.timeLeftMinutes);
const hours = Math.floor(time / 60);
const minutes = time - (hours * 60);
let text = minutes + ' ' + LocalizeText('catalog.marketplace.offer.minutes');
if(hours > 0)
{
text = hours + ' ' + LocalizeText('catalog.marketplace.offer.hours') + ' ' + text;
}
return LocalizeText('catalog.marketplace.offer.time_left', [ 'time' ], [ text ] );
}, [ offerData ]);
return (
<LayoutGridItem shrink alignItems="center" center={ false } className="p-1" column={ false }>
<Column style={ { width: 40, height: 40 } }>
<LayoutGridItem column={ false } itemImage={ GetImageIconUrlForProduct(((offerData.furniType === MarketplaceOfferData.TYPE_FLOOR) ? ProductTypeEnum.FLOOR : ProductTypeEnum.WALL), offerData.furniId, offerData.extraData) } itemUniqueNumber={ offerData.isUniqueLimitedItem ? offerData.stuffData.uniqueNumber : 0 } />
</Column>
<Column grow gap={ 0 }>
<Text fontWeight="bold">{ getMarketplaceOfferTitle }</Text>
{ (type === OWN_OFFER) &&
<>
<Text>{ LocalizeText('catalog.marketplace.offer.price_own_item', [ 'price' ], [ offerData.price.toString() ]) }</Text>
<Text>{ offerTime() }</Text>
</> }
{ (type === PUBLIC_OFFER) &&
<>
<Text>{ LocalizeText('catalog.marketplace.offer.price_public_item', [ 'price', 'average' ], [ offerData.price.toString(), ((offerData.averagePrice > 0) ? offerData.averagePrice.toString() : '-') ]) }</Text>
<Text>{ LocalizeText('catalog.marketplace.offer_count', [ 'count' ], [ offerData.offerCount.toString() ]) }</Text>
</> }
</Column>
<div className="flex flex-col gap-1">
{ ((type === OWN_OFFER) && (offerData.status !== MarketPlaceOfferState.SOLD)) &&
<Button variant="secondary" onClick={ () => onClick(offerData) }>
{ LocalizeText('catalog.marketplace.offer.pick') }
</Button> }
{ type === PUBLIC_OFFER &&
<>
<Button variant="secondary" onClick={ () => onClick(offerData) }>
{ LocalizeText('buy') }
</Button>
<Button disabled variant="secondary">
{ LocalizeText('catalog.marketplace.view_more') }
</Button>
</> }
</div>
</LayoutGridItem>
);
};
@@ -0,0 +1,116 @@
import { CancelMarketplaceOfferMessageComposer, GetMarketplaceOwnOffersMessageComposer, MarketplaceCancelOfferResultEvent, MarketplaceOwnOffersEvent, RedeemMarketplaceOfferCreditsMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { LocalizeText, MarketplaceOfferData, MarketPlaceOfferState, NotificationAlertType, SendMessageComposer } from '../../../../../../api';
import { Button, Column, Text } from '../../../../../../common';
import { useMessageEvent, useNotification } from '../../../../../../hooks';
import { CatalogLayoutProps } from '../CatalogLayout.types';
import { CatalogLayoutMarketplaceItemView, OWN_OFFER } from './CatalogLayoutMarketplaceItemView';
export const CatalogLayoutMarketplaceOwnItemsView: FC<CatalogLayoutProps> = props =>
{
const [ creditsWaiting, setCreditsWaiting ] = useState(0);
const [ offers, setOffers ] = useState<MarketplaceOfferData[]>([]);
const { simpleAlert = null } = useNotification();
const isRedeemingRef = useRef<boolean>(false);
const pendingCancelsRef = useRef<Set<number>>(new Set());
useMessageEvent<MarketplaceOwnOffersEvent>(MarketplaceOwnOffersEvent, event =>
{
const parser = event.getParser();
if(!parser) return;
const offers = parser.offers.map(offer =>
{
const newOffer = new MarketplaceOfferData(offer.offerId, offer.furniId, offer.furniType, offer.extraData, offer.stuffData, offer.price, offer.status, offer.averagePrice, offer.offerCount);
newOffer.timeLeftMinutes = offer.timeLeftMinutes;
return newOffer;
});
setCreditsWaiting(parser.creditsWaiting);
setOffers(offers);
});
useMessageEvent<MarketplaceCancelOfferResultEvent>(MarketplaceCancelOfferResultEvent, event =>
{
const parser = event.getParser();
if(!parser) return;
if(!parser.success)
{
simpleAlert(LocalizeText('catalog.marketplace.cancel_failed'), NotificationAlertType.DEFAULT, null, null, LocalizeText('catalog.marketplace.operation_failed.topic'));
return;
}
setOffers(prevValue => prevValue.filter(value => (value.offerId !== parser.offerId)));
});
const soldOffers = useMemo(() =>
{
return offers.filter(value => (value.status === MarketPlaceOfferState.SOLD));
}, [ offers ]);
const redeemSoldOffers = useCallback(() =>
{
if(isRedeemingRef.current) return;
isRedeemingRef.current = true;
setOffers(prevValue =>
{
const idsToDelete = soldOffers.map(value => value.offerId);
return prevValue.filter(value => (idsToDelete.indexOf(value.offerId) === -1));
});
SendMessageComposer(new RedeemMarketplaceOfferCreditsMessageComposer());
setTimeout(() => isRedeemingRef.current = false, 3000);
}, [ soldOffers ]);
const takeItemBack = (offerData: MarketplaceOfferData) =>
{
if(pendingCancelsRef.current.has(offerData.offerId)) return;
pendingCancelsRef.current.add(offerData.offerId);
SendMessageComposer(new CancelMarketplaceOfferMessageComposer(offerData.offerId));
setTimeout(() => pendingCancelsRef.current.delete(offerData.offerId), 2000);
};
useEffect(() =>
{
SendMessageComposer(new GetMarketplaceOwnOffersMessageComposer());
}, []);
return (
<Column overflow="hidden">
{ (creditsWaiting <= 0) &&
<Text center className="bg-muted rounded p-1">
{ LocalizeText('catalog.marketplace.redeem.no_sold_items') }
</Text> }
{ (creditsWaiting > 0) &&
<Column center className="bg-muted rounded p-2" gap={ 1 }>
<Text>
{ LocalizeText('catalog.marketplace.redeem.get_credits', [ 'count', 'credits' ], [ soldOffers.length.toString(), creditsWaiting.toString() ]) }
</Text>
<Button className="mt-1" onClick={ redeemSoldOffers }>
{ LocalizeText('catalog.marketplace.offer.redeem') }
</Button>
</Column> }
<Column gap={ 1 } overflow="hidden">
<Text shrink truncate fontWeight="bold">
{ LocalizeText('catalog.marketplace.items_found', [ 'count' ], [ offers.length.toString() ]) }
</Text>
<Column className="nitro-catalog-layout-marketplace-grid" overflow="auto">
{ (offers.length > 0) && offers.map(offer => <CatalogLayoutMarketplaceItemView key={ offer.offerId } offerData={ offer } type={ OWN_OFFER } onClick={ takeItemBack } />) }
</Column>
</Column>
</Column>
);
};
@@ -0,0 +1,167 @@
import { BuyMarketplaceOfferMessageComposer, GetMarketplaceOffersMessageComposer, MarketplaceBuyOfferResultEvent, MarketPlaceOffersEvent } from '@nitrots/nitro-renderer';
import { FC, useCallback, useMemo, useRef, useState } from 'react';
import { IMarketplaceSearchOptions, LocalizeText, MarketplaceOfferData, MarketplaceSearchType, NotificationAlertType, SendMessageComposer } from '../../../../../../api';
import { Button, Column, Text } from '../../../../../../common';
import { useMessageEvent, useNotification, usePurse } from '../../../../../../hooks';
import { CatalogLayoutProps } from '../CatalogLayout.types';
import { CatalogLayoutMarketplaceItemView, PUBLIC_OFFER } from './CatalogLayoutMarketplaceItemView';
import { SearchFormView } from './CatalogLayoutMarketplaceSearchFormView';
const SORT_TYPES_VALUE = [ 1, 2 ];
const SORT_TYPES_ACTIVITY = [ 3, 4, 5, 6 ];
const SORT_TYPES_ADVANCED = [ 1, 2, 3, 4, 5, 6 ];
export interface CatalogLayoutMarketplacePublicItemsViewProps extends CatalogLayoutProps
{
}
export const CatalogLayoutMarketplacePublicItemsView: FC<CatalogLayoutMarketplacePublicItemsViewProps> = props =>
{
const [ searchType, setSearchType ] = useState(MarketplaceSearchType.BY_ACTIVITY);
const [ totalItemsFound, setTotalItemsFound ] = useState(0);
const [ offers, setOffers ] = useState(new Map<number, MarketplaceOfferData>());
const [ lastSearch, setLastSearch ] = useState<IMarketplaceSearchOptions>({ minPrice: -1, maxPrice: -1, query: '', type: 3 });
const { getCurrencyAmount = null } = usePurse();
const { simpleAlert = null, showConfirm = null } = useNotification();
const isBuyingRef = useRef<boolean>(false);
const requestOffers = useCallback((options: IMarketplaceSearchOptions) =>
{
setLastSearch(options);
SendMessageComposer(new GetMarketplaceOffersMessageComposer(options.minPrice, options.maxPrice, options.query, options.type));
}, []);
const getSortTypes = useMemo(() =>
{
switch(searchType)
{
case MarketplaceSearchType.BY_ACTIVITY:
return SORT_TYPES_ACTIVITY;
case MarketplaceSearchType.BY_VALUE:
return SORT_TYPES_VALUE;
case MarketplaceSearchType.ADVANCED:
return SORT_TYPES_ADVANCED;
}
return [];
}, [ searchType ]);
const purchaseItem = useCallback((offerData: MarketplaceOfferData) =>
{
if(offerData.price > getCurrencyAmount(-1))
{
simpleAlert(LocalizeText('catalog.alert.notenough.credits.description'), NotificationAlertType.DEFAULT, null, null, LocalizeText('catalog.alert.notenough.title'));
return;
}
const offerId = offerData.offerId;
showConfirm(LocalizeText('catalog.marketplace.confirm_header'), () =>
{
if(isBuyingRef.current) return;
isBuyingRef.current = true;
SendMessageComposer(new BuyMarketplaceOfferMessageComposer(offerId));
},
null, null, null, LocalizeText('catalog.marketplace.confirm_title'));
}, [ getCurrencyAmount, simpleAlert, showConfirm ]);
useMessageEvent<MarketPlaceOffersEvent>(MarketPlaceOffersEvent, event =>
{
const parser = event.getParser();
if(!parser) return;
const latestOffers = new Map<number, MarketplaceOfferData>();
parser.offers.forEach(entry =>
{
const offerEntry = new MarketplaceOfferData(entry.offerId, entry.furniId, entry.furniType, entry.extraData, entry.stuffData, entry.price, entry.status, entry.averagePrice, entry.offerCount);
offerEntry.timeLeftMinutes = entry.timeLeftMinutes;
latestOffers.set(entry.offerId, offerEntry);
});
setTotalItemsFound(parser.totalItemsFound);
setOffers(latestOffers);
});
useMessageEvent<MarketplaceBuyOfferResultEvent>(MarketplaceBuyOfferResultEvent, event =>
{
const parser = event.getParser();
isBuyingRef.current = false;
if(!parser) return;
switch(parser.result)
{
case 1:
requestOffers(lastSearch);
break;
case 2:
setOffers(prev =>
{
const newVal = new Map(prev);
newVal.delete(parser.requestedOfferId);
return newVal;
});
simpleAlert(LocalizeText('catalog.marketplace.not_available_header'), NotificationAlertType.DEFAULT, null, null, LocalizeText('catalog.marketplace.not_available_title'));
break;
case 3:
// our shit was updated
// todo: some dialogue modal
setOffers(prev =>
{
const newVal = new Map(prev);
const item = newVal.get(parser.requestedOfferId);
if(item)
{
item.offerId = parser.offerId;
item.price = parser.newPrice;
item.offerCount--;
newVal.set(item.offerId, item);
}
newVal.delete(parser.requestedOfferId);
return newVal;
});
showConfirm(LocalizeText('catalog.marketplace.confirm_higher_header') +
'\n' + LocalizeText('catalog.marketplace.confirm_price', [ 'price' ], [ parser.newPrice.toString() ]), () =>
{
SendMessageComposer(new BuyMarketplaceOfferMessageComposer(parser.offerId));
},
null, null, null, LocalizeText('catalog.marketplace.confirm_higher_title'));
break;
case 4:
simpleAlert(LocalizeText('catalog.alert.notenough.credits.description'), NotificationAlertType.DEFAULT, null, null, LocalizeText('catalog.alert.notenough.title'));
break;
}
});
return (
<>
<div className="relative inline-flex align-middle">
<Button active={ (searchType === MarketplaceSearchType.BY_ACTIVITY) } onClick={ () => setSearchType(MarketplaceSearchType.BY_ACTIVITY) }>
{ LocalizeText('catalog.marketplace.search_by_activity') }
</Button>
<Button active={ (searchType === MarketplaceSearchType.BY_VALUE) } onClick={ () => setSearchType(MarketplaceSearchType.BY_VALUE) }>
{ LocalizeText('catalog.marketplace.search_by_value') }
</Button>
<Button active={ (searchType === MarketplaceSearchType.ADVANCED) } onClick={ () => setSearchType(MarketplaceSearchType.ADVANCED) }>
{ LocalizeText('catalog.marketplace.search_advanced') }
</Button>
</div>
<SearchFormView searchType={ searchType } sortTypes={ getSortTypes } onSearch={ requestOffers } />
<Column gap={ 1 } overflow="hidden">
<Text shrink truncate fontWeight="bold">
{ LocalizeText('catalog.marketplace.items_found', [ 'count' ], [ offers.size.toString() ]) }
</Text>
<Column className="nitro-catalog-layout-marketplace-grid" overflow="auto">
{
Array.from(offers.values()).map((entry, index) => <CatalogLayoutMarketplaceItemView key={ index } offerData={ entry } type={ PUBLIC_OFFER } onClick={ purchaseItem } />)
}
</Column>
</Column>
</>
);
};
@@ -0,0 +1,86 @@
import { FC, useCallback, useEffect, useState } from 'react';
import { IMarketplaceSearchOptions, LocalizeText, MarketplaceSearchType } from '../../../../../../api';
import { Button, Text } from '../../../../../../common';
import { NitroInput } from '../../../../../../layout';
export interface SearchFormViewProps
{
searchType: number;
sortTypes: number[];
onSearch(options: IMarketplaceSearchOptions): void;
}
export const SearchFormView: FC<SearchFormViewProps> = props =>
{
const { searchType = null, sortTypes = null, onSearch = null } = props;
const [ sortType, setSortType ] = useState(sortTypes ? sortTypes[0] : 3); // first item of SORT_TYPES_ACTIVITY
const [ searchQuery, setSearchQuery ] = useState('');
const [ min, setMin ] = useState(0);
const [ max, setMax ] = useState(0);
const onSortTypeChange = useCallback((sortType: number) =>
{
setSortType(sortType);
if((searchType === MarketplaceSearchType.BY_ACTIVITY) || (searchType === MarketplaceSearchType.BY_VALUE)) onSearch({ minPrice: -1, maxPrice: -1, query: '', type: sortType });
}, [ onSearch, searchType ]);
const onClickSearch = useCallback(() =>
{
const minPrice = ((min > 0) ? min : -1);
const maxPrice = ((max > 0) ? max : -1);
onSearch({ minPrice: minPrice, maxPrice: maxPrice, type: sortType, query: searchQuery });
}, [ max, min, onSearch, searchQuery, sortType ]);
useEffect(() =>
{
if(!sortTypes || !sortTypes.length) return;
const sortType = sortTypes[0];
setSortType(sortType);
if(searchType === MarketplaceSearchType.BY_ACTIVITY || MarketplaceSearchType.BY_VALUE === searchType) onSearch({ minPrice: -1, maxPrice: -1, query: '', type: sortType });
}, [ onSearch, searchType, sortTypes ]);
return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1">
<Text className="col-span-3">{ LocalizeText('catalog.marketplace.sort_order') }</Text>
<select className="form-select form-select-sm" value={ sortType } onChange={ event => onSortTypeChange(parseInt(event.target.value)) }>
{ sortTypes.map(type => <option key={ type } value={ type }>{ LocalizeText(`catalog.marketplace.sort.${ type }`) }</option>) }
</select>
</div>
{ searchType === MarketplaceSearchType.ADVANCED &&
<>
<div className="flex items-center gap-1">
<Text className="col-span-3">{ LocalizeText('catalog.marketplace.search_name') }</Text>
<NitroInput
value={ searchQuery }
onChange={ event => setSearchQuery(event.target.value) } />
</div>
<div className="flex items-center gap-1">
<Text className="col-span-3">{ LocalizeText('catalog.marketplace.search_price') }</Text>
<div className="flex w-full gap-1">
<NitroInput
min={ 0 }
type="number"
value={ min }
onChange={ event => setMin(event.target.valueAsNumber) } />
<NitroInput
min={ 0 }
type="number"
value={ max }
onChange={ event => setMax(event.target.valueAsNumber) } />
</div>
</div>
<Button className="mx-auto" variant="secondary" onClick={ onClickSearch }>{ LocalizeText('generic.search') }</Button>
</> }
</div>
);
};
@@ -0,0 +1,109 @@
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 { useMarketplaceConfiguration, useNotification, useUiEvent } from '../../../../../../hooks';
import { NitroInput } from '../../../../../../layout';
let isPostingMarketplaceOffer = false;
export const MarketplacePostOfferView: FC<{}> = props =>
{
const [ item, setItem ] = useState<FurnitureItem>(null);
const [ askingPrice, setAskingPrice ] = useState(0);
const [ tempAskingPrice, setTempAskingPrice ] = useState('0');
const { data: marketplaceConfiguration = null } = useMarketplaceConfiguration({ enabled: !!item });
const { showConfirm = null } = useNotification();
const updateAskingPrice = (price: string) =>
{
setTempAskingPrice(price);
const newValue = parseInt(price);
if(isNaN(newValue) || (newValue === askingPrice)) return;
setAskingPrice(parseInt(price));
};
useUiEvent<CatalogPostMarketplaceOfferEvent>(CatalogPostMarketplaceOfferEvent.POST_MARKETPLACE, event => setItem(event.item));
useEffect(() =>
{
if(!item) return;
return () => setAskingPrice(0);
}, [ item ]);
if(!marketplaceConfiguration || !item) return null;
const getFurniTitle = (item ? LocalizeText(item.isWallItem ? 'wallItem.name.' + item.type : 'roomItem.name.' + item.type) : '');
const getFurniDescription = (item ? LocalizeText(item.isWallItem ? 'wallItem.desc.' + item.type : 'roomItem.desc.' + item.type) : '');
const getCommission = () => Math.max(Math.ceil(((marketplaceConfiguration.commission * 0.01) * askingPrice)), 1);
const postItem = () =>
{
if(!item || (askingPrice < marketplaceConfiguration.minimumPrice) || isPostingMarketplaceOffer) return;
showConfirm(LocalizeText('inventory.marketplace.confirm_offer.info', [ 'furniname', 'price' ], [ getFurniTitle, askingPrice.toString() ]), () =>
{
if(isPostingMarketplaceOffer) return;
isPostingMarketplaceOffer = true;
setTimeout(() => isPostingMarketplaceOffer = false, 5000);
SendMessageComposer(new MakeOfferMessageComposer(askingPrice, item.isWallItem ? 2 : 1, item.id));
setItem(null);
},
() =>
{
setItem(null);
}, null, null, LocalizeText('inventory.marketplace.confirm_offer.title'));
};
return (
<NitroCardView className="nitro-catalog-layout-marketplace-post-offer" theme="primary-slim">
<NitroCardHeaderView headerText={ LocalizeText('inventory.marketplace.make_offer.title') } onCloseClick={ event => setItem(null) } />
<NitroCardContentView overflow="hidden">
<Grid fullHeight>
<Column center className="bg-muted rounded p-2" overflow="hidden" size={ 4 }>
<LayoutFurniImageView extraData={ item.extra.toString() } productClassId={ item.type } productType={ item.isWallItem ? ProductTypeEnum.WALL : ProductTypeEnum.FLOOR } />
</Column>
<Column justifyContent="between" overflow="hidden" size={ 8 }>
<Column grow gap={ 1 }>
<Text fontWeight="bold">{ getFurniTitle }</Text>
<Text shrink truncate>{ getFurniDescription }</Text>
</Column>
<Column overflow="auto">
<Text italics>
{ LocalizeText('inventory.marketplace.make_offer.expiration_info', [ 'time' ], [ marketplaceConfiguration.offerTime.toString() ]) }
</Text>
<div className="input-group has-validation">
<NitroInput min={ 0 } placeholder={ LocalizeText('inventory.marketplace.make_offer.price_request') } type="number" value={ tempAskingPrice } onChange={ event => updateAskingPrice(event.target.value) } />
{ ((askingPrice < marketplaceConfiguration.minimumPrice) || isNaN(askingPrice)) &&
<div className="invalid-feedback d-block">
{ LocalizeText('inventory.marketplace.make_offer.min_price', [ 'minprice' ], [ marketplaceConfiguration.minimumPrice.toString() ]) }
</div> }
{ ((askingPrice > marketplaceConfiguration.maximumPrice) && !isNaN(askingPrice)) &&
<div className="invalid-feedback d-block">
{ LocalizeText('inventory.marketplace.make_offer.max_price', [ 'maxprice' ], [ marketplaceConfiguration.maximumPrice.toString() ]) }
</div> }
{ (!((askingPrice < marketplaceConfiguration.minimumPrice) || (askingPrice > marketplaceConfiguration.maximumPrice) || isNaN(askingPrice))) &&
<div className="invalid-feedback d-block">
{ LocalizeText('inventory.marketplace.make_offer.final_price', [ 'commission', 'finalprice' ], [ getCommission().toString(), (askingPrice + getCommission()).toString() ]) }
</div> }
</div>
<Button disabled={ ((askingPrice < marketplaceConfiguration.minimumPrice) || (askingPrice > marketplaceConfiguration.maximumPrice) || isNaN(askingPrice)) } onClick={ postItem }>
{ LocalizeText('inventory.marketplace.make_offer.post') }
</Button>
</Column>
</Column>
</Grid>
</NitroCardContentView>
</NitroCardView>
);
};
@@ -0,0 +1,317 @@
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 { useCatalogData, useCatalogUiState, useMessageEvent, useSellablePetPalette } from '../../../../../../hooks';
import { useCatalogAdmin } from '../../../../CatalogAdminContext';
import { CatalogAddOnBadgeWidgetView } from '../../widgets/CatalogAddOnBadgeWidgetView';
import { CatalogTotalPriceWidget } from '../../widgets/CatalogTotalPriceWidget';
import { CatalogViewProductWidgetView } from '../../widgets/CatalogViewProductWidgetView';
import { CatalogLayoutProps } from '../CatalogLayout.types';
export const CatalogLayoutPetView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const [ petIndex, setPetIndex ] = useState(-1);
const [ sellablePalettes, setSellablePalettes ] = useState<SellablePetPaletteData[]>([]);
const [ selectedPaletteIndex, setSelectedPaletteIndex ] = useState(-1);
const [ sellableColors, setSellableColors ] = useState<number[][]>([]);
const [ selectedColorIndex, setSelectedColorIndex ] = useState(-1);
const [ colorsShowing, setColorsShowing ] = useState(false);
const [ petName, setPetName ] = useState('');
const [ approvalPending, setApprovalPending ] = useState(true);
const [ approvalResult, setApprovalResult ] = useState(-1);
const { currentOffer = null, roomPreviewer = null } = useCatalogData();
const { setCurrentOffer = null, setPurchaseOptions = null } = useCatalogUiState();
const catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
const breed: string = (currentOffer?.product?.productData?.type as unknown as string) ?? '';
const { data: petPalette = null } = useSellablePetPalette(breed);
const getColor = useMemo(() =>
{
if(!sellableColors.length || (selectedColorIndex === -1)) return 0xFFFFFF;
return sellableColors[selectedColorIndex][0];
}, [ sellableColors, selectedColorIndex ]);
const petBreedName = useMemo(() =>
{
if((petIndex === -1) || !sellablePalettes.length || (selectedPaletteIndex === -1)) return '';
return LocalizeText(`pet.breed.${ petIndex }.${ sellablePalettes[selectedPaletteIndex].breedId }`);
}, [ petIndex, sellablePalettes, selectedPaletteIndex ]);
const petPurchaseString = useMemo(() =>
{
if(!sellablePalettes.length || (selectedPaletteIndex === -1)) return '';
const paletteId = sellablePalettes[selectedPaletteIndex].paletteId;
let color = 0xFFFFFF;
if(petIndex <= 7)
{
if(selectedColorIndex === -1) return '';
color = sellableColors[selectedColorIndex][0];
}
let colorString = color.toString(16).toUpperCase();
while(colorString.length < 6) colorString = ('0' + colorString);
return `${ paletteId }\n${ colorString }`;
}, [ sellablePalettes, selectedPaletteIndex, petIndex, sellableColors, selectedColorIndex ]);
const validationErrorMessage = useMemo(() =>
{
let key: string = '';
switch(approvalResult)
{
case 1:
key = 'catalog.alert.petname.long';
break;
case 2:
key = 'catalog.alert.petname.short';
break;
case 3:
key = 'catalog.alert.petname.chars';
break;
case 4:
key = 'catalog.alert.petname.bobba';
break;
}
if(!key || !key.length) return '';
return LocalizeText(key);
}, [ approvalResult ]);
const purchasePet = useCallback(() =>
{
if(approvalResult === -1)
{
SendMessageComposer(new ApproveNameMessageComposer(petName, 1));
return;
}
if(approvalResult === 0)
{
SendMessageComposer(new PurchaseFromCatalogComposer(page.pageId, currentOffer.offerId, `${ petName }\n${ petPurchaseString }`, 1));
return;
}
}, [ page, currentOffer, petName, petPurchaseString, approvalResult ]);
useMessageEvent<ApproveNameMessageEvent>(ApproveNameMessageEvent, event =>
{
const parser = event.getParser();
setApprovalResult(parser.result);
if(parser.result === 0) purchasePet();
else DispatchUiEvent(new CatalogPurchaseFailureEvent(-1));
});
useEffect(() =>
{
if(!page || !page.offers.length) return;
const offer = page.offers[0];
setCurrentOffer(offer);
setPetIndex(GetPetIndexFromLocalization(offer.localizationId));
setColorsShowing(false);
}, [ page, setCurrentOffer ]);
useEffect(() =>
{
if(!currentOffer || !petPalette)
{
setSelectedPaletteIndex(-1);
setSellablePalettes([]);
return;
}
const palettes: SellablePetPaletteData[] = [];
for(const palette of petPalette.palettes)
{
if(!palette.sellable) continue;
palettes.push(palette);
}
setSelectedPaletteIndex(palettes.length ? 0 : -1);
setSellablePalettes(palettes);
}, [ currentOffer, petPalette ]);
useEffect(() =>
{
if(petIndex === -1) return;
const colors = GetPetAvailableColors(petIndex, sellablePalettes);
setSelectedColorIndex((colors.length ? 0 : -1));
setSellableColors(colors);
}, [ petIndex, sellablePalettes ]);
useEffect(() =>
{
if(!roomPreviewer) return;
roomPreviewer.reset(false);
if((petIndex === -1) || !sellablePalettes.length || (selectedPaletteIndex === -1)) return;
let petFigureString = `${ petIndex } ${ sellablePalettes[selectedPaletteIndex].paletteId }`;
if(petIndex <= 7) petFigureString += ` ${ getColor.toString(16) }`;
roomPreviewer.addPetIntoRoom(petFigureString);
}, [ roomPreviewer, petIndex, sellablePalettes, selectedPaletteIndex, getColor ]);
useEffect(() =>
{
setApprovalResult(-1);
}, [ petName ]);
if(!currentOffer) return null;
return (
<div className="flex flex-col h-full gap-2">
{ /* Admin: quick actions */ }
{ adminMode && !catalogAdmin.editingPageData &&
<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);
} }
>
<FaEdit className="text-[10px]" /> { LocalizeText('catalog.admin.edit.page') }
</button>
<button
className="flex items-center gap-1 text-[10px] text-success hover:text-green-800 transition-colors cursor-pointer"
onClick={ () => catalogAdmin.setEditingOffer({ offerId: -1, product: { productClassId: 0, productType: 'i', productCount: 1, extraParam: '' } } as any) }
>
<FaPlus className="text-[10px]" /> { LocalizeText('catalog.admin.offer.new') }
</button>
</div> }
{ /* Top card: preview + name + purchase */ }
<div className="flex gap-3 p-2.5 bg-white rounded border-2 border-card-grid-item-border">
{ /* Pet preview */ }
<div className="w-[160px] min-w-[160px] h-[140px] rounded overflow-hidden bg-card-grid-item relative flex items-center justify-center border border-card-grid-item-border">
<CatalogViewProductWidgetView />
<CatalogAddOnBadgeWidgetView className="bg-muted rounded absolute bottom-1 right-1" />
{ ((petIndex > -1) && (petIndex <= 7)) &&
<button
className={ `absolute bottom-1 left-1 w-[28px] h-[28px] rounded flex items-center justify-center cursor-pointer transition-all border ${ colorsShowing ? 'bg-primary text-white border-primary' : 'bg-white text-dark border-card-grid-item-border hover:bg-card-grid-item-active' }` }
title={ LocalizeText('catalog.pets.show.colors') }
onClick={ () => setColorsShowing(!colorsShowing) }
>
<FaFillDrip className="text-[10px]" />
</button> }
</div>
{ /* Pet info */ }
<div className="flex flex-col flex-1 justify-between min-w-0">
<div>
<div className="flex items-center gap-1.5">
<FaPaw className="text-primary text-xs" />
<span className="text-sm font-bold">{ petBreedName || LocalizeText('catalog.pet.breed') }</span>
{ adminMode && currentOffer &&
<FaEdit
className="text-primary text-[11px] cursor-pointer hover:text-dark transition-colors shrink-0"
title={ LocalizeText('catalog.admin.offer.edit') }
onClick={ () => catalogAdmin.setEditingOffer(currentOffer) }
/> }
</div>
{ adminMode && currentOffer &&
<div className="flex items-center gap-1 mt-0.5 flex-wrap">
<span className="text-[8px] font-mono text-white bg-gray-600 px-1 py-px rounded">ID: { currentOffer.product.productClassId }</span>
<span className="text-[8px] font-mono text-white bg-primary px-1 py-px rounded">Offer: { currentOffer.offerId }</span>
</div> }
{ !!page.localization.getText(0) &&
<p className="text-[10px] text-muted mt-0.5" dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } /> }
</div>
{ /* Name input */ }
<div className="flex flex-col gap-1 mt-2">
<label className="text-[9px] text-muted uppercase font-bold">{ LocalizeText('widgets.petpackage.name.title') }</label>
<div className="relative">
<input
className={ `w-full text-[11px] border-2 rounded px-2 py-1.5 focus:outline-none transition-colors ${ approvalResult > 0 ? 'border-danger bg-danger/5' : approvalResult === 0 ? 'border-success bg-success/5' : 'border-card-grid-item-border focus:border-primary bg-white' }` }
placeholder={ LocalizeText('widgets.petpackage.name.title') }
type="text"
value={ petName }
onChange={ event => setPetName(event.target.value) }
/>
{ approvalResult === 0 &&
<FaCheck className="absolute right-2 top-1/2 -translate-y-1/2 text-success text-[10px]" /> }
{ approvalResult > 0 &&
<FaTimes className="absolute right-2 top-1/2 -translate-y-1/2 text-danger text-[10px]" /> }
</div>
{ (approvalResult > 0) &&
<span className="text-[10px] text-danger font-medium">{ validationErrorMessage }</span> }
</div>
{ /* Price + buy */ }
<div className="flex items-center justify-between mt-2">
<CatalogTotalPriceWidget />
<button
className="px-3 py-1 rounded text-[11px] font-bold bg-primary text-white hover:bg-secondary transition-colors cursor-pointer disabled:opacity-50"
disabled={ !petName.length || (approvalResult > 0) }
onClick={ purchasePet }
>
{ approvalResult === -1 ? LocalizeText('catalog.purchase_confirmation.buy') : LocalizeText('catalog.marketplace.confirm_title') }
</button>
</div>
</div>
</div>
{ /* Breed/Color grid */ }
<div className="flex-1 overflow-auto min-h-0">
<div className="flex items-center gap-1.5 mb-1.5">
<span className="text-[10px] font-bold text-muted uppercase tracking-wide">
{ colorsShowing ? LocalizeText('catalog.pets.choose.color') : LocalizeText('catalog.pets.choose.breed') }
</span>
{ colorsShowing &&
<button
className="text-[9px] text-primary hover:text-dark cursor-pointer transition-colors"
onClick={ () => setColorsShowing(false) }
>
{ LocalizeText('catalog.pets.back.breeds') }
</button> }
</div>
<div className="grid grid-cols-6 gap-1">
{ !colorsShowing && (sellablePalettes.length > 0) && sellablePalettes.map((palette, index) => (
<LayoutGridItem
key={ index }
className="group/pet"
itemActive={ (selectedPaletteIndex === index) }
onClick={ () => setSelectedPaletteIndex(index) }
>
<LayoutPetImageView direction={ 2 } headOnly={ true } paletteId={ palette.paletteId } typeId={ petIndex } />
</LayoutGridItem>
)) }
{ colorsShowing && (sellableColors.length > 0) && sellableColors.map((colorSet, index) => (
<div
key={ index }
className={ `w-full aspect-square rounded border-2 cursor-pointer transition-all ${ selectedColorIndex === index ? 'border-primary scale-110 shadow-md' : 'border-card-grid-item-border hover:border-primary/50' }` }
style={ { backgroundColor: `#${ ColorConverter.int2rgb(colorSet[0]) }` } }
onClick={ () => setSelectedColorIndex(index) }
/>
)) }
</div>
</div>
</div>
);
};
@@ -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<CatalogLayoutProps> = 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 (
<>
<Text shrink truncate fontWeight="bold">{ giftsAvailable() }</Text>
<AutoGrid className="nitro-catalog-layout-vip-gifts-grid" columnCount={ 1 }>
{ 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>
</>
);
};
@@ -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<VipGiftItemViewProps> = 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 (
<LayoutGridItem alignItems="center" center={ false } className="p-1" column={ false }>
<LayoutImage imageUrl={ getImageUrlForOffer() } />
<Text grow fontWeight="bold">{ getItemTitle() }</Text>
<Button disabled={ !isAvailable } variant="secondary" onClick={ () => onSelect(offer.localizationId) }>
{ LocalizeText('catalog.club_gift.select') }
</Button>
</LayoutGridItem>
);
};
@@ -0,0 +1,18 @@
import { FC } from 'react';
import { BaseProps, LayoutBadgeImageView } from '../../../../../common';
import { useCatalogData } from '../../../../../hooks';
interface CatalogAddOnBadgeWidgetViewProps extends BaseProps<HTMLDivElement>
{
}
export const CatalogAddOnBadgeWidgetView: FC<CatalogAddOnBadgeWidgetViewProps> = props =>
{
const { ...rest } = props;
const { currentOffer = null } = useCatalogData();
if(!currentOffer || !currentOffer.badgeCode || !currentOffer.badgeCode.length) return null;
return <LayoutBadgeImageView badgeCode={ currentOffer.badgeCode } { ...rest } />;
};
@@ -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<CatalogBadgeSelectorWidgetViewProps> = props =>
{
const { columnCount = 5, ...rest } = props;
const [ isVisible, setIsVisible ] = useState(false);
const [ currentBadgeCode, setCurrentBadgeCode ] = useState<string>(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 (
<AutoGrid columnCount={ columnCount } { ...rest }>
{ badgeCodes && (badgeCodes.length > 0) && badgeCodes.map((badgeCode, index) =>
{
return (
<LayoutGridItem key={ index } itemActive={ (currentBadgeCode === badgeCode) } onClick={ event => setCurrentBadgeCode(badgeCode) }>
<LayoutBadgeImageView badgeCode={ badgeCode } />
</LayoutGridItem>
);
}) }
</AutoGrid>
);
};
@@ -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<CatalogBundleGridWidgetViewProps> = props =>
{
const { columnCount = 5, children = null, ...rest } = props;
const { currentOffer = null } = useCatalogData();
const elementRef = useRef<HTMLDivElement>(null);
useEffect(() =>
{
if(elementRef && elementRef.current) elementRef.current.scrollTop = 0;
}, [ currentOffer ]);
if(!currentOffer) return null;
return (
<AutoGrid columnCount={ 5 } innerRef={ elementRef } { ...rest }>
{ currentOffer.products && (currentOffer.products.length > 0) && currentOffer.products.map((product, index) => <LayoutGridItem key={ index } itemCount={ product.productCount } itemImage={ getFurniIconUrl(product) } />) }
{ children }
</AutoGrid>
);
};
@@ -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;
};
@@ -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<HTMLDivElement>
{
}
export const CatalogGuildBadgeWidgetView: FC<CatalogGuildBadgeWidgetViewProps> = 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 <LayoutBadgeImageView badgeCode={ badgeCode } isGroup={ true } { ...rest } />;
};
@@ -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<number>(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 (
<div className="bg-muted rounded p-1 text-black text-center">
{ LocalizeText('catalog.guild_selector.members_only') }
<Button className="mt-1">
{ LocalizeText('catalog.guild_selector.find_groups') }
</Button>
</div>
);
}
const selectedGroup = groups[selectedGroupIndex];
return (
<div className="flex gap-1">
{ !!selectedGroup &&
<Flex className="rounded border" overflow="hidden">
<div className="h-full" style={ { width: '20px', backgroundColor: '#' + selectedGroup.colorA } } />
<div className="h-full" style={ { width: '20px', backgroundColor: '#' + selectedGroup.colorB } } />
</Flex> }
<select className="form-select form-select-sm" value={ selectedGroupIndex } onChange={ event => setSelectedGroupIndex(parseInt(event.target.value)) }>
{ groups.map((group, index) => <option key={ index } value={ index }>{ group.groupName }</option>) }
</select>
</div>
);
};
@@ -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<CatalogItemGridWidgetViewProps> = 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<HTMLDivElement>(null);
const [ dragIndex, setDragIndex ] = useState<number | null>(null);
const [ dropIndex, setDropIndex ] = useState<number | null>(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 (
<AutoGrid columnCount={ columnCount } innerRef={ elementRef } { ...rest }>
{ currentPage.offers && (currentPage.offers.length > 0) && currentPage.offers.map((offer, index) =>
{
const isDragging = dragIndex === index;
const isDropTarget = dropIndex === index && dragIndex !== index;
return (
<div
key={ index }
className={ `${ isDragging ? 'opacity-40' : '' } ${ isDropTarget ? 'ring-2 ring-primary ring-offset-1 rounded' : '' }` }
draggable={ adminMode }
onDragEnd={ adminMode ? handleDragEnd : undefined }
onDragOver={ adminMode ? (e) => handleDragOver(e, index) : undefined }
onDragStart={ adminMode ? () => handleDragStart(index) : undefined }
onDrop={ adminMode ? () => handleDrop(index) : undefined }
>
<CatalogGridOfferView
itemActive={ (currentOffer && (currentOffer.offerId === offer.offerId)) }
offer={ offer }
selectOffer={ selectOffer }
/>
</div>
);
}) }
{ children }
</AutoGrid>
);
};
@@ -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 (
<div className="w-full">
<LayoutLimitedEditionCompletePlateView className="mx-auto" uniqueLimitedItemsLeft={ currentOffer.product.uniqueLimitedItemsLeft } uniqueLimitedSeriesSize={ currentOffer.product.uniqueLimitedItemSeriesSize } />
</div>
);
};
@@ -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<CatalogPriceDisplayWidgetViewProps> = props =>
{
const { offer = null, separator = false } = props;
const { purchaseOptions = null } = useCatalogUiState();
const { quantity = 1 } = purchaseOptions;
if(!offer) return null;
return (
<div className="flex items-center gap-1.5">
{ (offer.priceInCredits > 0) &&
<div className="flex items-center gap-1 bg-warning/15 border border-warning/40 rounded-full px-2 py-0.5">
<Text className="text-[11px]! font-bold text-dark">{ (offer.priceInCredits * quantity) }</Text>
<LayoutCurrencyIcon type={ -1 } />
</div> }
{ separator && (offer.priceInCredits > 0) && (offer.priceInActivityPoints > 0) &&
<FaPlus className="text-[7px] text-muted" /> }
{ (offer.priceInActivityPoints > 0) &&
<div className="flex items-center gap-1 bg-purple/15 border border-purple/40 rounded-full px-2 py-0.5">
<Text className="text-[11px]! font-bold text-dark">{ (offer.priceInActivityPoints * quantity) }</Text>
<LayoutCurrencyIcon type={ offer.activityPointType } />
</div> }
</div>
);
};
@@ -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<CatalogPurchaseWidgetViewProps> = props =>
{
const { noGiftOption = false, purchaseCallback = null } = props;
const [ builderPlaceableRefreshTick, setBuilderPlaceableRefreshTick ] = useState(0);
const [ purchaseWillBeGift, setPurchaseWillBeGift ] = useState(false);
const [ purchaseState, setPurchaseState ] = useState(CatalogPurchaseState.NONE);
const [ catalogSkipPurchaseConfirmation, setCatalogSkipPurchaseConfirmation ] = useLocalStorage(LocalStorageKeys.CATALOG_SKIP_PURCHASE_CONFIRMATION, false);
const { currentOffer = null, currentPage = null } = 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<typeof setTimeout> = 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 (
<div className="flex flex-col gap-1.5 items-start">
<div className="flex gap-1.5 flex-wrap">
<Button disabled={ isDisabled } onClick={ () => startBuilderPlacement(true) }>
{ LocalizeText('builder.placement_widget.place_many') }
</Button>
<Button disabled={ isDisabled } onClick={ () => startBuilderPlacement(false) } style={ buildersClubPlaceOneButtonStyle }>
{ LocalizeText('builder.placement_widget.place_one') }
</Button>
</div>
{ isBlockedByVisitors &&
<Text className="max-w-full" small variant="danger">
{ LocalizeText('builder.placement_widget.error.visitors') }
</Text> }
{ (builderPlaceableStatus === BuilderFurniPlaceableStatus.NOT_GROUP_ADMIN) &&
<Text className="max-w-full" small variant="danger">
{ LocalizeText('builder.placement_widget.error.not_group_admin') }
</Text> }
</div>
);
}
const priceCredits = (currentOffer.priceInCredits * purchaseOptions.quantity);
const pricePoints = (currentOffer.priceInActivityPoints * purchaseOptions.quantity);
if(GetClubMemberLevel() < currentOffer.clubLevel) return <Button disabled variant="danger">{ LocalizeText('catalog.alert.hc.required') }</Button>;
if(isLimitedSoldOut) return <Button disabled variant="danger">{ LocalizeText('catalog.alert.limited_edition_sold_out.title') }</Button>;
if(priceCredits > getCurrencyAmount(-1)) return <Button disabled variant="danger">{ LocalizeText('catalog.alert.notenough.title') }</Button>;
if(pricePoints > getCurrencyAmount(currentOffer.activityPointType)) return <Button disabled variant="danger">{ LocalizeText('catalog.alert.notenough.activitypoints.title.' + currentOffer.activityPointType) }</Button>;
switch(purchaseState)
{
case CatalogPurchaseState.CONFIRM:
return <Button variant="warning" onClick={ event => purchase() }>{ LocalizeText('catalog.marketplace.confirm_title') }</Button>;
case CatalogPurchaseState.PURCHASE:
return <Button disabled><LayoutLoadingSpinnerView /></Button>;
case CatalogPurchaseState.FAILED:
return <Button variant="danger">{ LocalizeText('generic.failed') }</Button>;
case CatalogPurchaseState.SOLD_OUT:
return <Button variant="danger">{ LocalizeText('generic.failed') + ' - ' + LocalizeText('catalog.alert.limited_edition_sold_out.title') }</Button>;
case CatalogPurchaseState.NONE:
default:
return <Button variant="success" disabled={ (purchaseOptions.extraParamRequired && (!purchaseOptions.extraData || !purchaseOptions.extraData.length)) } onClick={ event => setPurchaseState(CatalogPurchaseState.CONFIRM) }>{ LocalizeText('catalog.purchase_confirmation.' + (currentOffer.isRentOffer ? 'rent' : 'buy')) }</Button>;
}
};
return (
<>
<PurchaseButton />
{ (!isBuildersClubOffer && !noGiftOption && !currentOffer.isRentOffer) &&
<Button disabled={ ((purchaseOptions.quantity > 1) || !currentOffer.giftable || isLimitedSoldOut || (purchaseOptions.extraParamRequired && (!purchaseOptions.extraData || !purchaseOptions.extraData.length))) } onClick={ event => purchase(true) }>
{ LocalizeText('catalog.purchase_confirmation.gift') }
</Button> }
</>
);
};
@@ -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 (
<div className="flex items-center bg-muted p-1 rounded gap-1">
<CatalogPriceDisplayWidgetView offer={ currentOffer } separator={ true } />
</div>
);
};
@@ -0,0 +1,7 @@
import { FC } from 'react';
import { CatalogFirstProductSelectorWidgetView } from './CatalogFirstProductSelectorWidgetView';
export const CatalogSingleViewWidgetView: FC<{}> = props =>
{
return <CatalogFirstProductSelectorWidgetView />;
};
@@ -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<CatalogSpacesWidgetViewProps> = props =>
{
const { columnCount = 5, children = null, ...rest } = props;
const [ groupedOffers, setGroupedOffers ] = useState<IPurchasableOffer[][]>(null);
const [ selectedGroupIndex, setSelectedGroupIndex ] = useState(-1);
const [ selectedOfferForGroup, setSelectedOfferForGroup ] = useState<IPurchasableOffer[]>(null);
const { currentPage = null, currentOffer = null } = useCatalogData();
const { setCurrentOffer = null, setPurchaseOptions = null } = useCatalogUiState();
const elementRef = useRef<HTMLDivElement>(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 (
<>
<div className="relative inline-flex align-middle">
{ SPACES_GROUP_NAMES.map((name, index) => <Button key={ index } active={ (selectedGroupIndex === index) } onClick={ event => setSelectedGroupIndex(index) }>{ LocalizeText(`catalog.spaces.tab.${ name }`) }</Button>) }
</div>
<AutoGrid columnCount={ columnCount } innerRef={ elementRef } { ...rest }>
{ offers && (offers.length > 0) && offers.map((offer, index) => <CatalogGridOfferView key={ index } itemActive={ (currentOffer && (currentOffer === offer)) } offer={ offer } selectOffer={ offer => setSelectedOffer(offer) } />) }
{ children }
</AutoGrid>
</>
);
};
@@ -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 (
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-muted whitespace-nowrap">{ LocalizeText('catalog.bundlewidget.spinner.select.amount') }</span>
<div className="flex items-center rounded overflow-hidden border-2 border-card-grid-item-border">
<button
className="w-[24px] h-[24px] flex items-center justify-center bg-card-grid-item hover:bg-card-grid-item-active transition-colors cursor-pointer border-r border-card-grid-item-border"
onClick={ event => updateQuantity(quantity - 1) }
>
<FaMinus className="text-[7px] text-dark" />
</button>
<input
className="w-[40px] h-[24px] text-center text-[11px] font-bold bg-white border-x border-card-grid-item-border [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none focus:outline-none"
type="number"
value={ quantity }
onChange={ event => updateQuantity(event.target.valueAsNumber) }
/>
<button
className="w-[24px] h-[24px] flex items-center justify-center bg-card-grid-item hover:bg-card-grid-item-active transition-colors cursor-pointer border-l border-card-grid-item-border"
onClick={ event => updateQuantity(quantity + 1) }
>
<FaPlus className="text-[7px] text-dark" />
</button>
</div>
</div>
);
};
@@ -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<CatalogSimplePriceWidgetViewProps> = props =>
{
const { gap = 1, ...rest } = props;
const { currentOffer = null } = useCatalogData();
return (
<Column gap={ gap } { ...rest }>
<CatalogPriceDisplayWidgetView offer={ currentOffer } />
</Column>
);
};
@@ -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 (
<Column fit className="bg-muted p-2 rounded" overflow="hidden">
<AutoGrid fullWidth className="nitro-catalog-layout-bundle-grid" columnCount={ 4 }>
{ (currentOffer.products.length > 0) && currentOffer.products.map((product, index) =>
{
return <LayoutGridItem key={ index } itemCount={ product.productCount } itemImage={ product.getIconUrl(currentOffer) } />;
}) }
</AutoGrid>
</Column>
);
}
return <LayoutRoomPreviewerView key={ currentOffer?.offerId } height={ 240 } roomPreviewer={ roomPreviewer } />;
};
@@ -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<SetStateAction<boolean>> }) =>
{
const { offer = null, setOpen = null } = props;
if(!offer) return;
return <LayoutNotificationBubbleView fadesOut={ false } gap={ 2 } onClick={ evt => setOpen(true) } onClose={ null }>
<div className="nitro-targeted-offer-icon" style={ { backgroundImage: `url(${ GetConfigurationValue('image.library.url') + offer.iconImageUrl })` } }/>
<Text className="ubuntu-bold" variant="light">{ offer.title }</Text>
</LayoutNotificationBubbleView>;
};
@@ -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<TargetedOfferEvent, TargetedOfferData>({
key: [ 'nitro', 'catalog', 'targeted-offer' ],
request: () => new GetTargetedOfferComposer(),
parser: TargetedOfferEvent,
select: evt => evt.getParser()?.data ?? null,
staleTime: Infinity
});
const [ opened, setOpened ] = useState<boolean>(false);
if(!offer) return null;
return (
<>
{ opened
? <OfferWindowView offer={ offer } setOpen={ setOpened } />
: <OfferBubbleView offer={ offer } setOpen={ setOpened } /> }
</>
);
};
@@ -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<SetStateAction<boolean>> }) =>
{
const { offer = null, setOpen = null } = props;
const { getCurrencyAmount } = usePurse();
const [ amount, setAmount ] = useState<number>(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 <NitroCardView className="nitro-targeted-offer" theme="primary-slim" uniqueKey="targeted-offer">
<NitroCardHeaderView headerText={ LocalizeText(offer.title) } onCloseClick={ event => setOpen(false) } />
<div className="container-fluid p-1 relative justify-center items-center cursor-pointer gap-3 bg-danger">
{ LocalizeText('targeted.offer.timeleft', [ 'timeleft' ], [ expirationTime() ]) }
</div>
<NitroCardContentView gap={ 1 }>
<Flex fullHeight gap={ 1 }>
<Flex column className="w-75 text-black" gap={ 1 }>
<Column fullHeight className="bg-warning p-2">
<h4>
{ LocalizeText(offer.title) }
</h4>
<div dangerouslySetInnerHTML={ { __html: SanitizeHtml(offer.description) } } />
</Column>
<Flex alignItems="center" alignSelf="center" gap={ 2 } justifyContent="center">
{ offer.purchaseLimit > 1 &&
<div className="flex gap-1">
<Text variant="muted">{ LocalizeText('catalog.bundlewidget.quantity') }</Text>
<input max={ offer.purchaseLimit } min={ 1 } type="number" value={ amount } onChange={ evt => setAmount(parseInt(evt.target.value)) } />
</div> }
<Button disabled={ !canPurchase } variant="primary" onClick={ () => buyOffer() }>{ LocalizeText('targeted.offer.button.buy') }</Button>
</Flex>
</Flex>
<div className="w-50 h-full" style={ { background: `url(${ GetConfigurationValue('image.library.url') + offer.imageUrl }) no-repeat center` } } />
</Flex>
<Flex column alignItems="center" className="price-ray absolute" justifyContent="center">
<Text>{ LocalizeText('targeted.offer.price.label') }</Text>
{ offer.priceInCredits > 0 &&
<div className="flex gap-1">
<Text variant="light">{ offer.priceInCredits }</Text>
<LayoutCurrencyIcon type={ -1 } />
</div> }
{ offer.priceInActivityPoints > 0 &&
<div className="flex gap-1">
<Text className="ubuntu-bold" variant="light">+{ offer.priceInActivityPoints }</Text> <LayoutCurrencyIcon type={ offer.activityPointType } />
</div> }
</Flex>
</NitroCardContentView>
</NitroCardView>;
};
+7 -4
View File
@@ -1,16 +1,19 @@
import { FC } from 'react'; import { FC } from 'react';
import { useCatalogClassicStyle, useCatalogData } from '../../hooks'; import { useCatalogClassicStyle, useCatalogData } from '../../hooks';
import { CatalogClassicView } from './CatalogClassicView'; 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<{}> = () => export const CatalogView: FC<{}> = () =>
{ {
const { catalogLocalizationVersion = 0 } = useCatalogData(); const { catalogLocalizationVersion = 0 } = useCatalogData();
const [ catalogClassicStyle ] = useCatalogClassicStyle(); const [ catalogClassicStyle ] = useCatalogClassicStyle();
// Default = upstream rebuilt catalog (CatalogClassicView, latest release theme). // Default (toggle OFF) = duckie's classic catalog 1:1 upstream (./CatalogClassicView,
// The "stile classico" toggle (or global catalog.classic.style flag) switches // uses the original ./views/* tree). Toggle ON = the modern catalog, a fully
// to the Hippiehotel.nl catalog (CatalogModernView, self-contained tailwind). // self-contained fork under ../catalog-modern/* — nothing shared between the two.
// Both the normal catalog and the Builders Club follow this toggle. // Both the normal catalog and the Builders Club follow this toggle.
if(catalogClassicStyle) return ( if(catalogClassicStyle) return (
<> <>
+102
View File
@@ -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; }
+1
View File
@@ -20,6 +20,7 @@ import './css/index.css';
import './css/backgrounds/BackgroundsView.css'; import './css/backgrounds/BackgroundsView.css';
import './css/badges/BadgeLeaderboardView.css'; import './css/badges/BadgeLeaderboardView.css';
import './css/catalog/CatalogClassicView.css'; import './css/catalog/CatalogClassicView.css';
import './css/catalog/CatalogModern.css';
import './css/emustats/EmuStatsView.css'; import './css/emustats/EmuStatsView.css';
import './css/chat/Chats.css'; import './css/chat/Chats.css';