mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
feat(catalog): catalogo modern Hippiehotel in albero separato (catalog-modern)
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+47
@@ -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">›</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') } "{ prefixText[selectedLetterIndex] || '' }"
|
||||||
|
</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 } />;
|
||||||
|
}
|
||||||
|
};
|
||||||
+37
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+49
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+83
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+116
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+167
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
+86
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+109
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+67
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+17
@@ -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>;
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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; }
|
||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user