feat: catalog style toggle (classic/new) with admin mode & favorites

This commit is contained in:
Life
2026-03-22 16:54:40 +01:00
parent ccaec9185e
commit a5ea88010e
34 changed files with 2799 additions and 575 deletions
+2
View File
@@ -28,3 +28,5 @@ Thumbs.db
*.zip *.zip
.env .env
.claude/ .claude/
public/renderer-config.json
public/ui-config.json
+68
View File
@@ -0,0 +1,68 @@
{
"catalog.title": "Catalog",
"catalog.favorites": "Favorites",
"catalog.favorites.pages": "Pages",
"catalog.favorites.furni": "Furni",
"catalog.favorites.empty": "No favorites",
"catalog.favorites.empty.hint": "Click the heart on furni or the star on pages to add them.",
"catalog.admin": "Admin",
"catalog.admin.new": "New",
"catalog.admin.root": "Root",
"catalog.admin.new.root.category": "New root category",
"catalog.admin.edit.root": "Edit Root",
"catalog.admin.edit": "Edit:",
"catalog.admin.edit.page": "Edit Page",
"catalog.admin.hidden": "hidden",
"catalog.admin.edit.title": "Edit \"%name%\"",
"catalog.admin.show": "Show",
"catalog.admin.hide": "Hide",
"catalog.admin.delete": "Delete",
"catalog.admin.delete.title": "Delete \"%name%\"",
"catalog.admin.delete.category.confirm": "Delete category \"%name%\" and all its content?",
"catalog.admin.delete.page": "Delete page",
"catalog.admin.delete.page.confirm": "Delete page \"%name%\"?",
"catalog.admin.delete.offer.confirm": "Are you sure you want to delete this offer?",
"catalog.admin.create": "Create",
"catalog.admin.save": "Save",
"catalog.admin.create.subpage": "Create sub-page",
"catalog.admin.order": "Order",
"catalog.admin.visible": "Visible",
"catalog.admin.enabled": "Enabled",
"catalog.admin.offer.new": "New Offer",
"catalog.admin.offer.edit": "Edit Offer",
"catalog.admin.offer.name": "Catalog Name",
"catalog.admin.offer.general": "General",
"catalog.admin.offer.quantity": "Quantity",
"catalog.admin.offer.prices": "Prices",
"catalog.admin.offer.credits": "Credits",
"catalog.admin.offer.points": "Points",
"catalog.admin.offer.points.type": "Points Type",
"catalog.admin.offer.options": "Options",
"catalog.admin.offer.club.only": "Club Only",
"catalog.admin.offer.extradata": "Extra Data",
"catalog.admin.offer.have.offer": "Multi-discount (have_offer)",
"catalog.trophies.title": "Trophies",
"catalog.trophies.write.hint": "Write a text for the trophy before purchasing",
"catalog.trophies.inscription": "Trophy Inscription",
"catalog.trophies.inscription.placeholder": "Write the text that will appear on the trophy...",
"catalog.pets.show.colors": "Show colors",
"catalog.pets.choose.color": "Choose color",
"catalog.pets.choose.breed": "Choose breed",
"catalog.pets.back.breeds": "← Breeds",
"catalog.prefix.text": "Text",
"catalog.prefix.text.placeholder": "Enter text...",
"catalog.prefix.icon": "Icon",
"catalog.prefix.icon.remove": "Remove icon",
"catalog.prefix.effect": "Effect",
"catalog.prefix.color": "Color",
"catalog.prefix.color.single": "🎨 Single",
"catalog.prefix.color.per.letter": "🌈 Per Letter",
"catalog.prefix.color.hint": "Select a letter, then choose the color. Auto-advances.",
"catalog.prefix.color.apply.all.title": "Apply current color to all letters",
"catalog.prefix.color.apply.all": "Apply to all",
"catalog.prefix.color.selected": "Selected letter:",
"catalog.prefix.price": "Price:",
"catalog.prefix.price.amount": "5 Credits",
"catalog.prefix.purchased": "✓ Purchased!",
"catalog.prefix.purchase": "Purchase"
}
+7
View File
@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS catalog_favorites (
user_id INT NOT NULL,
type ENUM('offer', 'page') NOT NULL,
target_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, type, target_id)
);
+2 -1
View File
@@ -1348,8 +1348,9 @@
"catalog.asset.url": "${image.library.url}catalogue", "catalog.asset.url": "${image.library.url}catalogue",
"catalog.asset.image.url": "${catalog.asset.url}/%name%.gif", "catalog.asset.image.url": "${catalog.asset.url}/%name%.gif",
"catalog.asset.icon.url": "${catalog.asset.url}/icon_%name%.png", "catalog.asset.icon.url": "${catalog.asset.url}/icon_%name%.png",
"catalog.tab.icons": false, "catalog.tab.icons": true,
"catalog.headers": false, "catalog.headers": false,
"catalog.style": "old",
"chat.input.maxlength": 100, "chat.input.maxlength": 100,
"chat.styles.disabled": [], "chat.styles.disabled": [],
"chat.styles": [{ "chat.styles": [{
@@ -0,0 +1,296 @@
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 { useMessageEvent, useNotification } from '../../hooks';
export interface IPageEditData
{
pageId?: number;
caption: string;
parentId: number;
pageLayout: string;
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 [ 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();
// Keyboard shortcuts: Esc to close edit panels
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.caption, data.pageLayout, 0,
data.minRank, data.visible === '1', data.enabled === '1',
data.orderNum, data.parentId,
data.pageHeadline || '', data.pageTeaser || '', data.pageTextDetails || ''
));
}, []);
const createPage = useCallback((data: IPageEditData) =>
{
setLoading(true);
setLastError(null);
pendingActionRef.current = 'createPage';
SendMessageComposer(new CatalogAdminCreatePageComposer(
data.caption, data.caption, data.pageLayout, 0,
data.minRank, data.visible === '1', data.enabled === '1',
data.orderNum, data.parentId
));
}, []);
const deletePage = useCallback((pageId: number) =>
{
setLoading(true);
setLastError(null);
pendingActionRef.current = 'deletePage';
SendMessageComposer(new CatalogAdminDeletePageComposer(pageId));
}, []);
const saveOffer = useCallback((data: IOfferEditData) =>
{
setLoading(true);
setLastError(null);
pendingActionRef.current = 'saveOffer';
SendMessageComposer(new CatalogAdminSaveOfferComposer(
data.offerId || 0, data.pageId, parseInt(data.itemIds) || 0,
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
));
}, []);
const createOffer = useCallback((data: IOfferEditData) =>
{
setLoading(true);
setLastError(null);
pendingActionRef.current = 'createOffer';
SendMessageComposer(new CatalogAdminCreateOfferComposer(
data.pageId, parseInt(data.itemIds) || 0,
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
));
}, []);
const deleteOffer = useCallback((offerId: number) =>
{
setLoading(true);
setLastError(null);
pendingActionRef.current = 'deleteOffer';
SendMessageComposer(new CatalogAdminDeleteOfferComposer(offerId));
}, []);
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));
}
}, []);
const reorderPage = useCallback((pageId: number, newParentId: number, newIndex: number) =>
{
setLoading(true);
setLastError(null);
pendingActionRef.current = 'movePage';
SendMessageComposer(new CatalogAdminMovePageComposer(pageId, newParentId, newIndex));
}, []);
const togglePageEnabled = useCallback((pageId: number) =>
{
setLoading(true);
setLastError(null);
pendingActionRef.current = 'toggleEnabled';
SendMessageComposer(new CatalogAdminMovePageComposer(pageId, -1, -1));
}, []);
const togglePageVisible = useCallback((pageId: number) =>
{
setLoading(true);
setLastError(null);
pendingActionRef.current = 'toggleVisible';
SendMessageComposer(new CatalogAdminMovePageComposer(pageId, -2, -1));
}, []);
const publishCatalog = useCallback(() =>
{
setLoading(true);
setLastError(null);
pendingActionRef.current = 'publish';
SendMessageComposer(new CatalogAdminPublishComposer());
}, []);
return (
<CatalogAdminContext.Provider 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.Provider>
);
};
@@ -0,0 +1,183 @@
import { AddLinkEventTracker, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useEffect } from 'react';
import { FaCog, FaEdit, FaEye, FaEyeSlash, FaPlus, FaTrash } from 'react-icons/fa';
import { GetConfigurationValue, LocalizeText } from '../../api';
import { Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common';
import { useCatalog } from '../../hooks';
import { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext';
import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView';
import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView';
import { CatalogIconView } from './views/catalog-icon/CatalogIconView';
import { CatalogGiftView } from './views/gift/CatalogGiftView';
import { CatalogNavigationView } from './views/navigation/CatalogNavigationView';
import { GetCatalogLayout } from './views/page/layout/GetCatalogLayout';
import { MarketplacePostOfferView } from './views/page/layout/marketplace/MarketplacePostOfferView';
const CatalogClassicViewInner: FC<{}> = () =>
{
const { isVisible = false, setIsVisible = null, rootNode = null, currentPage = null, navigationHidden = false, setNavigationHidden = null, activeNodes = [], searchResult = null, setSearchResult = null, openPageByName = null, openPageByOfferId = null, activateNode = null } = useCatalog();
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 isMod = GetSessionDataManager().isModerator;
useEffect(() =>
{
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) =>
{
const parts = url.split('/');
if(parts.length < 2) return;
switch(parts[1])
{
case 'show':
setIsVisible(true);
return;
case 'hide':
setIsVisible(false);
return;
case 'toggle':
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 ]);
return (
<>
{ isVisible &&
<NitroCardView className="w-[630px] h-[400px]" style={ GetConfigurationValue('catalog.headers') ? { width: 710 } : {} } uniqueKey="catalog">
<NitroCardHeaderView headerText={ LocalizeText('catalog.title') } onCloseClick={ () => setIsVisible(false) } />
{ /* 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 ? '...' : '⬆ Publish' }
</button>
</div> }
<NitroCardTabsView>
{ rootNode && (rootNode.children.length > 0) && rootNode.children.map((child, index) =>
{
if(!adminMode && !child.isVisible) return null;
const isHidden = !child.isVisible;
return (
<NitroCardTabsItemView key={ `${ child.pageId }-${ child.pageName }-${ index }` } isActive={ child.isActive } onClick={ () =>
{
if(searchResult) setSearchResult(null);
activateNode(child);
} } >
<div className={ `flex items-center gap-${ GetConfigurationValue('catalog.tab.icons') ? 1 : 0 } ${ isHidden ? 'opacity-40' : '' }` }>
{ GetConfigurationValue('catalog.tab.icons') && <CatalogIconView icon={ child.iconId } /> }
{ child.localization }
{ adminMode && isHidden && <FaEyeSlash className="text-[8px] text-danger ml-1" /> }
{ adminMode &&
<div className="flex items-center gap-0.5 ml-1" onClick={ e => e.stopPropagation() }>
<FaEdit className="text-[8px] text-primary cursor-pointer hover:text-dark" title={ LocalizeText('catalog.admin.edit.title') }
onClick={ () => { catalogAdmin.setEditingPageNode(child); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true); } } />
<span className="cursor-pointer" title={ isHidden ? LocalizeText('catalog.admin.show') : LocalizeText('catalog.admin.hide') }
onClick={ () => catalogAdmin.togglePageVisible(child.pageId) }>
{ isHidden ? <FaEye className="text-[8px] text-success" /> : <FaEyeSlash className="text-[8px] text-muted" /> }
</span>
<FaTrash className="text-[8px] text-danger cursor-pointer hover:text-red-800" title={ LocalizeText('catalog.admin.delete.title') }
onClick={ () => { if(confirm(LocalizeText('catalog.admin.delete.category.confirm', [ 'name' ], [ child.localization ]))) catalogAdmin.deletePage(child.pageId); } } />
</div> }
</div>
</NitroCardTabsItemView>
);
}) }
{ /* Admin toggle button in tabs bar */ }
{ isMod &&
<NitroCardTabsItemView isActive={ adminMode } onClick={ () => setAdminMode(!adminMode) }>
<FaCog className={ `text-[10px] ${ adminMode ? 'animate-spin' : '' }` } style={ adminMode ? { animationDuration: '3s' } : {} } />
</NitroCardTabsItemView> }
</NitroCardTabsView>
<NitroCardContentView>
{ /* Admin: add new root category */ }
{ adminMode && rootNode &&
<div className="flex items-center gap-2 mb-1">
<button
className="flex items-center gap-1 text-[9px] text-success hover:text-green-800 cursor-pointer transition-colors"
onClick={ () => catalogAdmin.createPage({ caption: 'New Category', pageLayout: 'default_3x3', minRank: 1, visible: '1', enabled: '1', orderNum: 99, parentId: rootNode.pageId }) }
>
<FaPlus className="text-[8px]" />
<span>{ LocalizeText('catalog.admin.new') }</span>
</button>
<button
className="flex items-center gap-1 text-[9px] text-primary hover:text-dark cursor-pointer transition-colors"
onClick={ () => { catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(true); catalogAdmin.setEditingPageData(true); } }
>
<FaEdit className="text-[8px]" />
<span>{ LocalizeText('catalog.admin.root') }</span>
</button>
</div> }
<Grid>
{ !navigationHidden &&
<Column overflow="hidden" size={ 3 }>
{ activeNodes && (activeNodes.length > 0) &&
<CatalogNavigationView node={ activeNodes[0] } /> }
</Column> }
<Column overflow="hidden" size={ !navigationHidden ? 9 : 12 }>
{ adminMode && <CatalogAdminPageEditView /> }
{ GetCatalogLayout(currentPage, () => setNavigationHidden(true)) }
</Column>
</Grid>
</NitroCardContentView>
</NitroCardView> }
<CatalogAdminOfferEditView />
<CatalogGiftView />
<MarketplacePostOfferView />
</>
);
};
export const CatalogClassicView: FC<{}> = () =>
{
return (
<CatalogAdminProvider>
<CatalogClassicViewInner />
</CatalogAdminProvider>
);
};
@@ -0,0 +1,291 @@
import { AddLinkEventTracker, GetSessionDataManager, 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 { LocalizeText } from '../../api';
import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common';
import { useCatalog, useCatalogFavorites } from '../../hooks';
import { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext';
import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView';
import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView';
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 { isVisible = false, setIsVisible = null, rootNode = null, currentPage = null, navigationHidden = false, setNavigationHidden = null, activeNodes = [], searchResult = null, setSearchResult = null, openPageByName = null, openPageByOfferId = null, activateNode = null } = useCatalog();
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 = GetSessionDataManager().isModerator;
const totalFavs = favoriteOfferIds.length + favoritePageIds.length;
useEffect(() =>
{
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) =>
{
const parts = url.split('/');
if(parts.length < 2) return;
switch(parts[1])
{
case 'show':
setIsVisible(true);
return;
case 'hide':
setIsVisible(false);
return;
case 'toggle':
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 ]);
return (
<>
{ isVisible &&
<NitroCardView className="nitro-catalog w-[780px] h-[520px]" uniqueKey="catalog">
<NitroCardHeaderView headerText={ LocalizeText('catalog.title') } onCloseClick={ () => setIsVisible(false) } />
<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> }
<div className="flex h-full">
{ /* === LEFT SIDEBAR === */ }
<div className="group/rail flex flex-col w-[52px] 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">
{ /* 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-[28px] h-[24px] 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', pageLayout: 'default_3x3', 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-[28px] h-[24px] 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 } 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-[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 bg-card-content-area">
<CatalogFavoritesView onClose={ () => setShowFavorites(false) } />
</div>
: <>
{ !navigationHidden && activeNodes && activeNodes.length > 0 &&
<div className="w-[170px] 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 bg-card-content-area">
{ adminMode && <CatalogAdminPageEditView /> }
{ GetCatalogLayout(currentPage, () => setNavigationHidden(true)) }
</div>
</> }
</div>
</div>
</div>
</NitroCardContentView>
</NitroCardView> }
<CatalogAdminOfferEditView />
<CatalogGiftView />
<MarketplacePostOfferView />
</>
);
};
export const CatalogModernView: FC<{}> = () =>
{
return (
<CatalogAdminProvider>
<CatalogModernViewInner />
</CatalogAdminProvider>
);
};
+8 -106
View File
@@ -1,111 +1,13 @@
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; import { FC } from 'react';
import { FC, useEffect } from 'react'; import { GetConfigurationValue } from '../../api';
import { GetConfigurationValue, LocalizeText } from '../../api'; import { CatalogClassicView } from './CatalogClassicView';
import { Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common'; import { CatalogModernView } from './CatalogModernView';
import { useCatalog } from '../../hooks';
import { CatalogIconView } from './views/catalog-icon/CatalogIconView';
import { CatalogGiftView } from './views/gift/CatalogGiftView';
import { CatalogNavigationView } from './views/navigation/CatalogNavigationView';
import { GetCatalogLayout } from './views/page/layout/GetCatalogLayout';
import { MarketplacePostOfferView } from './views/page/layout/marketplace/MarketplacePostOfferView';
export const CatalogView: FC<{}> = props => export const CatalogView: FC<{}> = () =>
{ {
const { isVisible = false, setIsVisible = null, rootNode = null, currentPage = null, navigationHidden = false, setNavigationHidden = null, activeNodes = [], searchResult = null, setSearchResult = null, openPageByName = null, openPageByOfferId = null, activateNode = null, getNodeById } = useCatalog(); const style = GetConfigurationValue<string>('catalog.style', 'classic');
useEffect(() => if(style === 'new') return <CatalogModernView />;
{
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) =>
{
const parts = url.split('/');
if(parts.length < 2) return; return <CatalogClassicView />;
switch(parts[1])
{
case 'show':
setIsVisible(true);
return;
case 'hide':
setIsVisible(false);
return;
case 'toggle':
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 ]);
return (
<>
{ isVisible &&
<NitroCardView className="w-[630px] h-[400px]" style={ GetConfigurationValue('catalog.headers') ? { width: 710 } : {} } uniqueKey="catalog">
<NitroCardHeaderView headerText={ LocalizeText('catalog.title') } onCloseClick={ event => setIsVisible(false) } />
<NitroCardTabsView>
{ rootNode && (rootNode.children.length > 0) && rootNode.children.map((child, index) =>
{
if(!child.isVisible) return null;
return (
<NitroCardTabsItemView key={ `${ child.pageId }-${ child.pageName }-${ index }` } isActive={ child.isActive } onClick={ event =>
{
if(searchResult) setSearchResult(null);
activateNode(child);
} } >
<div className={ `flex items-center gap-${ GetConfigurationValue('catalog.tab.icons') ? 1 : 0 }` }>
{ GetConfigurationValue('catalog.tab.icons') && <CatalogIconView icon={ child.iconId } /> }
{ child.localization }
</div>
</NitroCardTabsItemView>
);
}) }
</NitroCardTabsView>
<NitroCardContentView>
<Grid>
{ !navigationHidden &&
<Column overflow="hidden" size={ 3 }>
{ activeNodes && (activeNodes.length > 0) &&
<CatalogNavigationView node={ activeNodes[0] } /> }
</Column> }
<Column overflow="hidden" size={ !navigationHidden ? 9 : 12 }>
{ GetCatalogLayout(currentPage, () => setNavigationHidden(true)) }
</Column>
</Grid>
</NitroCardContentView>
</NitroCardView> }
<CatalogGiftView />
<MarketplacePostOfferView />
</>
);
}; };
@@ -0,0 +1,225 @@
import { FC, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { FaSave, FaSpinner, FaTimes, FaTrash } from 'react-icons/fa';
import { LocalizeText } from '../../../../api';
import { useCatalog } from '../../../../hooks';
import { IOfferEditData, useCatalogAdmin } from '../../CatalogAdminContext';
export const CatalogAdminOfferEditView: FC<{}> = () =>
{
const { currentPage = null } = useCatalog();
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('0');
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('0');
setCatalogName('');
setCostCredits(0);
setCostPoints(0);
setPointsType(0);
setAmount(1);
setClubOnly('0');
setExtradata('');
setHaveOffer('1');
setOfferIdGroup(-1);
setLimitedStack(0);
setOrderNumber(0);
}
else
{
setIsNew(false);
setItemIds(String(editingOffer.product?.productClassId || 0));
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('1');
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
};
const success = isNew ? await createOffer(data) : await saveOffer(data);
if(success && 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 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="relative bg-light rounded-lg w-[420px] border-2 border-card-border overflow-hidden shadow-lg" onClick={ e => e.stopPropagation() }>
{ /* Header */ }
<div className="flex items-center justify-between px-3 py-2 bg-card-header">
<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" 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="dati extra (opzionale)" 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,153 @@
import { FC, useEffect, useState } from 'react';
import { FaSave, FaSpinner, FaTimes, FaTrash } from 'react-icons/fa';
import { LocalizeText } from '../../../../api';
import { useCatalog } 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', 'marketplace', 'marketplace_own_items',
'recycler', 'recycler_info', 'recycler_prizes',
'info_loyalty', 'badge_display', 'bots', 'single_bundle',
'color_grouping', 'recent_purchases', 'custom_prefix'
];
export const CatalogAdminPageEditView: FC<{}> = () =>
{
const { currentPage = null, activeNodes = [], rootNode = null } = useCatalog();
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 [ pageLayout, setPageLayout ] = useState('default_3x3');
const [ minRank, setMinRank ] = useState(1);
const [ visible, setVisible ] = useState('1');
const [ enabled, setEnabled ] = useState('1');
const [ orderNum, setOrderNum ] = useState(0);
// Resolve what we're editing:
// 1. editingPageNode (explicit node from sidebar click)
// 2. editingRootPage (root button)
// 3. current active page (from "Modifica Pagina" in layout)
const 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;
setCaption(targetNode.localization || '');
setPageLayout(currentPage?.layoutCode || 'default_3x3');
setVisible(targetNode.isVisible ? '1' : '0');
setEnabled('1');
setMinRank(1);
setOrderNum(0);
}, [ editingPageData, targetNode, currentPage ]);
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 parentNode = targetNode.parent;
const data: IPageEditData = {
pageId: targetPageId,
caption,
pageLayout,
minRank,
visible,
enabled,
orderNum,
parentId: parentNode ? parentNode.pageId : -1,
};
const success = await catalogAdmin.savePage(data);
if(success) closeForm();
};
const handleDelete = async () =>
{
if(!catalogAdmin?.deletePage || isRoot) return;
if(!confirm(LocalizeText('catalog.admin.delete.page.confirm', [ 'name' ], [ targetNode.localization ]))) return;
const success = await catalogAdmin.deletePage(targetPageId);
if(success) closeForm();
};
return (
<div className="bg-white rounded border-2 border-card-grid-item-border p-2.5 mb-2">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-bold text-primary uppercase tracking-wide">
{ isRoot ? LocalizeText('catalog.admin.edit.root') : `${ LocalizeText('catalog.admin.edit') } ${ targetNode.localization } (#${ targetPageId })` }
</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">
<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 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>
<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,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-[30px] h-[30px] flex items-center justify-center shrink-0">
<CatalogIconView icon={ node.iconId } />
</div>
<span className={ `text-[11px] font-medium whitespace-nowrap overflow-hidden opacity-0 group-hover:opacity-100 transition-opacity duration-200 truncate ${ isActive ? 'text-catalog-accent' : 'text-catalog-text' }` }>
{ node.localization }
</span>
</div>
);
};
@@ -0,0 +1,154 @@
import { FC, useMemo } from 'react';
import { FaHeart, FaStar, FaTimes } from 'react-icons/fa';
import { ICatalogNode, LocalizeText } from '../../../../api';
import { useCatalog, 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, activateNode, openPageByOfferId, rootNode } = useCatalog();
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-[28px] h-[28px] 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,39 @@
import { FC } from 'react';
import { FaChevronRight, FaHome } from 'react-icons/fa';
import { LocalizeText } from '../../../../api';
import { useCatalog } from '../../../../hooks';
export const CatalogBreadcrumbView: FC<{}> = () =>
{
const { activeNodes = [], activateNode } = useCatalog();
if(!activeNodes || activeNodes.length === 0)
{
return (
<div className="flex items-center gap-1.5 text-xs text-catalog-text-muted">
<FaHome className="text-[10px]" />
<span>{ LocalizeText('catalog.title') }</span>
</div>
);
}
return (
<div className="flex items-center gap-1 text-[11px] text-catalog-text-muted overflow-hidden min-w-0">
<FaHome
className="text-[10px] cursor-pointer hover:text-catalog-accent transition-colors shrink-0"
onClick={ () => activateNode(activeNodes[0]) }
/>
{ activeNodes.map((node, i) => (
<span key={ node.pageId } className="flex items-center gap-1 min-w-0">
<FaChevronRight className="text-[7px] opacity-30 shrink-0" />
<span
className={ `truncate ${ i === activeNodes.length - 1 ? 'text-catalog-text font-semibold' : 'cursor-pointer hover:text-catalog-accent transition-colors' }` }
onClick={ i < activeNodes.length - 1 ? () => activateNode(node) : undefined }
>
{ node.localization }
</span>
</span>
)) }
</div>
);
};
@@ -1,8 +1,8 @@
import { FC } from 'react'; import { FC, useCallback, useRef, useState } from 'react';
import { FaCaretDown, FaCaretUp } from 'react-icons/fa'; import { FaArrowsAlt, FaCaretDown, FaCaretUp, FaPlus, FaStar, FaTrash } from 'react-icons/fa';
import { ICatalogNode } from '../../../../api'; import { ICatalogNode, LocalizeText } from '../../../../api';
import { LayoutGridItem, Text } from '../../../../common'; import { useCatalog, useCatalogFavorites } from '../../../../hooks';
import { useCatalog } from '../../../../hooks'; import { useCatalogAdmin } from '../../CatalogAdminContext';
import { CatalogIconView } from '../catalog-icon/CatalogIconView'; import { CatalogIconView } from '../catalog-icon/CatalogIconView';
import { CatalogNavigationSetView } from './CatalogNavigationSetView'; import { CatalogNavigationSetView } from './CatalogNavigationSetView';
@@ -16,18 +16,122 @@ export const CatalogNavigationItemView: FC<CatalogNavigationItemViewProps> = pro
{ {
const { node = null, child = false } = props; const { node = null, child = false } = props;
const { activateNode = null } = useCatalog(); const { activateNode = null } = useCatalog();
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 ( return (
<div className={ child ? 'pl-[5px] border-s-2 border-[#b6bec5]' : '' }> <div className={ child ? 'pl-1.5 ml-1.5 border-l-2 border-card-grid-item-border' : '' }>
<LayoutGridItem className={ ' h-[23px]! bg-[#cdd3d9] border-0! px-[3px] py-px text-sm' } column={ false } gap={ 1 } itemActive={ node.isActive } onClick={ event => activateNode(node) }> <div
<CatalogIconView icon={ node.iconId } /> ref={ dragRef }
<Text truncate className="grow!">{ node.localization }</Text> className={ `group/nav flex items-center gap-1.5 px-1.5 py-[3px] mx-0.5 rounded cursor-pointer transition-all duration-100 text-[11px] ${ node.isActive ? 'bg-card-grid-item-active border border-card-grid-item-border-active shadow-inner1px font-bold' : 'border border-transparent hover:bg-card-grid-item-active' } ${ isDragOver ? 'ring-2 ring-primary ring-offset-1 bg-primary/10' : '' }` }
draggable={ adminMode }
onClick={ () => activateNode(node) }
onDragLeave={ adminMode ? handleDragLeave : undefined }
onDragOver={ adminMode ? handleDragOver : undefined }
onDragStart={ adminMode ? handleDragStart : undefined }
onDrop={ adminMode ? handleDrop : undefined }
>
{ adminMode &&
<FaArrowsAlt className="text-[7px] text-muted cursor-grab shrink-0 opacity-0 group-hover/nav:opacity-60" /> }
<div className="w-[20px] h-[20px] flex items-center justify-center shrink-0">
<CatalogIconView icon={ node.iconId } />
</div>
<span className="flex-1 truncate" title={ adminMode ? `Page ID: ${ node.pageId }` : undefined }>{ node.localization }</span>
{ adminMode &&
<div className="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',
pageLayout: 'default_3x3',
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 && { node.isBranch &&
<> <span className="text-[9px] text-muted shrink-0">
{ node.isOpen && <FaCaretUp className="fa-icon" /> } { node.isOpen ? <FaCaretUp /> : <FaCaretDown /> }
{ !node.isOpen && <FaCaretDown className="fa-icon" /> } </span> }
</> } </div>
</LayoutGridItem>
{ node.isOpen && node.isBranch && { node.isOpen && node.isBranch &&
<CatalogNavigationSetView child={ true } node={ node } /> } <CatalogNavigationSetView child={ true } node={ node } /> }
</div> </div>
@@ -1,8 +1,6 @@
import { FC } from 'react'; import { FC } from 'react';
import { ICatalogNode } from '../../../../api'; import { ICatalogNode } from '../../../../api';
import { AutoGrid, Column } from '../../../../common';
import { useCatalog } from '../../../../hooks'; import { useCatalog } from '../../../../hooks';
import { CatalogSearchView } from '../page/common/CatalogSearchView';
import { CatalogNavigationItemView } from './CatalogNavigationItemView'; import { CatalogNavigationItemView } from './CatalogNavigationItemView';
import { CatalogNavigationSetView } from './CatalogNavigationSetView'; import { CatalogNavigationSetView } from './CatalogNavigationSetView';
@@ -17,18 +15,13 @@ export const CatalogNavigationView: FC<CatalogNavigationViewProps> = props =>
const { searchResult = null } = useCatalog(); const { searchResult = null } = useCatalog();
return ( return (
<> <div className="flex flex-col gap-px px-0.5 py-0.5">
<CatalogSearchView /> { searchResult && (searchResult.filteredNodes.length > 0) && searchResult.filteredNodes.map((n, index) =>
<Column fullHeight className="border-[#b6bec5]! bg-[#cdd3d9] border-2 border-[solid] rounded p-1" overflow="hidden"> {
<AutoGrid columnCount={ 1 } gap={ 1 } id="nitro-catalog-main-navigation"> return <CatalogNavigationItemView key={ index } node={ n } />;
{ searchResult && (searchResult.filteredNodes.length > 0) && searchResult.filteredNodes.map((n, index) => }) }
{ { !searchResult &&
return <CatalogNavigationItemView key={ index } node={ n } />; <CatalogNavigationSetView node={ node } /> }
}) } </div>
{ !searchResult &&
<CatalogNavigationSetView node={ node } /> }
</AutoGrid>
</Column>
</>
); );
}; };
@@ -1,8 +1,9 @@
import { MouseEventType } from '@nitrots/nitro-renderer'; import { MouseEventType } from '@nitrots/nitro-renderer';
import { FC, MouseEvent, useMemo, useState } from 'react'; import { FC, MouseEvent, useMemo, useState } from 'react';
import { FaHeart } from 'react-icons/fa';
import { IPurchasableOffer, Offer, ProductTypeEnum } from '../../../../../api'; import { IPurchasableOffer, Offer, ProductTypeEnum } from '../../../../../api';
import { LayoutAvatarImageView, LayoutGridItem, LayoutGridItemProps } from '../../../../../common'; import { LayoutAvatarImageView, LayoutGridItem, LayoutGridItemProps } from '../../../../../common';
import { useCatalog, useInventoryFurni } from '../../../../../hooks'; import { useCatalog, useCatalogFavorites, useInventoryFurni } from '../../../../../hooks';
interface CatalogGridOfferViewProps extends LayoutGridItemProps interface CatalogGridOfferViewProps extends LayoutGridItemProps
{ {
@@ -16,6 +17,8 @@ export const CatalogGridOfferView: FC<CatalogGridOfferViewProps> = props =>
const [ isMouseDown, setMouseDown ] = useState(false); const [ isMouseDown, setMouseDown ] = useState(false);
const { requestOfferToMover = null } = useCatalog(); const { requestOfferToMover = null } = useCatalog();
const { isVisible = false } = useInventoryFurni(); const { isVisible = false } = useInventoryFurni();
const { isFavoriteOffer, toggleFavoriteOffer } = useCatalogFavorites();
const isFav = isFavoriteOffer(offer.offerId);
const iconUrl = useMemo(() => const iconUrl = useMemo(() =>
{ {
@@ -51,9 +54,28 @@ export const CatalogGridOfferView: FC<CatalogGridOfferViewProps> = props =>
if(!product) return null; if(!product) return null;
return ( return (
<LayoutGridItem itemActive={ itemActive } itemCount={ ((offer.pricingModel === Offer.PRICING_MODEL_MULTI) ? product.productCount : 1) } itemImage={ iconUrl } itemUniqueNumber={ product.uniqueLimitedItemSeriesSize } itemUniqueSoldout={ (product.uniqueLimitedItemSeriesSize && !product.uniqueLimitedItemsLeft) } onMouseDown={ onMouseEvent } onMouseOut={ onMouseEvent } onMouseUp={ onMouseEvent } { ...rest }> <LayoutGridItem
className="group/tile relative"
itemActive={ itemActive }
itemCount={ ((offer.pricingModel === Offer.PRICING_MODEL_MULTI) ? product.productCount : 1) }
itemImage={ iconUrl }
itemUniqueNumber={ product.uniqueLimitedItemSeriesSize }
itemUniqueSoldout={ (product.uniqueLimitedItemSeriesSize && !product.uniqueLimitedItemsLeft) }
title={ `ID: ${ product.productClassId } | Offer: ${ offer.offerId }` }
onMouseDown={ onMouseEvent }
onMouseOut={ onMouseEvent }
onMouseUp={ onMouseEvent }
{ ...rest }
>
{ (offer.product.productType === ProductTypeEnum.ROBOT) && { (offer.product.productType === ProductTypeEnum.ROBOT) &&
<LayoutAvatarImageView direction={ 3 } figure={ offer.product.extraParam } headOnly={ true } /> } <LayoutAvatarImageView direction={ 3 } figure={ offer.product.extraParam } headOnly={ true } /> }
<div
className={ `absolute top-0 right-0 z-10 p-0.5 cursor-pointer transition-opacity duration-100 ${ isFav ? 'opacity-100' : 'opacity-0 group-hover/tile:opacity-100' }` }
onClick={ e => { e.stopPropagation(); e.preventDefault(); toggleFavoriteOffer(offer.offerId, offer.localizationName, iconUrl); } }
onMouseDown={ e => e.stopPropagation() }
>
<FaHeart className={ `text-[10px] drop-shadow transition-colors duration-100 ${ isFav ? 'text-danger' : 'text-muted hover:text-danger' }` } />
</div>
</LayoutGridItem> </LayoutGridItem>
); );
}; };
@@ -2,11 +2,9 @@ import { GetSessionDataManager, IFurnitureData } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react'; import { FC, useEffect, useState } from 'react';
import { FaSearch, FaTimes } from 'react-icons/fa'; import { FaSearch, FaTimes } from 'react-icons/fa';
import { CatalogPage, CatalogType, FilterCatalogNode, FurnitureOffer, GetOfferNodes, ICatalogNode, ICatalogPage, IPurchasableOffer, LocalizeText, PageLocalization, SearchResult } from '../../../../../api'; import { CatalogPage, CatalogType, FilterCatalogNode, FurnitureOffer, GetOfferNodes, ICatalogNode, ICatalogPage, IPurchasableOffer, LocalizeText, PageLocalization, SearchResult } from '../../../../../api';
import { Button, Flex } from '../../../../../common';
import { useCatalog } from '../../../../../hooks'; import { useCatalog } from '../../../../../hooks';
import { NitroInput } from '../../../../../layout';
export const CatalogSearchView: FC<{}> = props => export const CatalogSearchView: FC<{}> = () =>
{ {
const [ searchValue, setSearchValue ] = useState(''); const [ searchValue, setSearchValue ] = useState('');
const { currentType = null, rootNode = null, offersToNodes = null, searchResult = null, setSearchResult = null, setCurrentPage = null } = useCatalog(); const { currentType = null, rootNode = null, offersToNodes = null, searchResult = null, setSearchResult = null, setCurrentPage = null } = useCatalog();
@@ -78,29 +76,22 @@ export const CatalogSearchView: FC<{}> = props =>
}, [ offersToNodes, currentType, rootNode, searchValue, setCurrentPage, setSearchResult ]); }, [ offersToNodes, currentType, rootNode, searchValue, setCurrentPage, setSearchResult ]);
return ( return (
<div className="flex gap-1"> <div className="relative w-full">
<Flex fullWidth alignItems="center" position="relative"> <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) }
<NitroInput />
placeholder={ LocalizeText('generic.search') } { searchValue && searchValue.length > 0 &&
value={ searchValue } <button
onChange={ event => setSearchValue(event.target.value) } /> 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('') }
>
</Flex> <FaTimes />
{ (!searchValue || !searchValue.length) && </button> }
<Button className="catalog-search-button" variant="primary">
<FaSearch className="fa-icon" />
</Button> }
{ searchValue && !!searchValue.length &&
<Button className="catalog-search-button" variant="primary" onClick={ event => setSearchValue('') }>
<FaTimes className="fa-icon" />
</Button> }
</div> </div>
); );
}; };
@@ -1,7 +1,6 @@
import { PurchasePrefixComposer } from '@nitrots/nitro-renderer'; import { PurchasePrefixComposer } from '@nitrots/nitro-renderer';
import { createPortal } from 'react-dom';
import { FC, useEffect, useMemo, useRef, useState } from 'react'; import { FC, useEffect, useMemo, useRef, useState } from 'react';
import { SendMessageComposer, PRESET_PREFIX_EFFECTS, parsePrefixColors, getPrefixEffectStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../../api'; import { LocalizeText, SendMessageComposer, PRESET_PREFIX_EFFECTS, parsePrefixColors, getPrefixEffectStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../../api';
import { CatalogLayoutProps } from './CatalogLayout.types'; import { CatalogLayoutProps } from './CatalogLayout.types';
import data from '@emoji-mart/data'; import data from '@emoji-mart/data';
import Picker from '@emoji-mart/react'; import Picker from '@emoji-mart/react';
@@ -32,32 +31,6 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
const [ showIconPicker, setShowIconPicker ] = useState(false); const [ showIconPicker, setShowIconPicker ] = useState(false);
const [ selectedEffect, setSelectedEffect ] = useState(''); const [ selectedEffect, setSelectedEffect ] = useState('');
const [ purchased, setPurchased ] = useState(false); const [ purchased, setPurchased ] = useState(false);
const pickerContainerRef = useRef<HTMLDivElement>(null);
// Inject style into emoji-mart Shadow DOM to remove backdrop-filter blur
useEffect(() =>
{
if(!showIconPicker) return;
const timer = setTimeout(() =>
{
const container = pickerContainerRef.current;
if(!container) return;
const emPicker = container.querySelector('em-emoji-picker');
if(!emPicker?.shadowRoot) return;
const existing = emPicker.shadowRoot.querySelector('#no-blur-fix');
if(existing) return;
const style = document.createElement('style');
style.id = 'no-blur-fix';
style.textContent = `.sticky { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; background-color: rgb(var(--em-rgb-background)) !important; } .menu { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; background-color: rgb(var(--em-rgb-background)) !important; }`;
emPicker.shadowRoot.appendChild(style);
}, 50);
return () => clearTimeout(timer);
}, [ showIconPicker ]);
const colorString = useMemo(() => const colorString = useMemo(() =>
{ {
@@ -104,7 +77,7 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
setLetterColors(prev => ({ ...prev, [selectedLetterIndex]: color })); setLetterColors(prev => ({ ...prev, [selectedLetterIndex]: color }));
setCustomColorInput(color); setCustomColorInput(color);
// Auto-advance to next letter // Auto-avanza alla lettera successiva
if(selectedLetterIndex < prefixText.length - 1) if(selectedLetterIndex < prefixText.length - 1)
{ {
const nextIdx = selectedLetterIndex + 1; const nextIdx = selectedLetterIndex + 1;
@@ -194,12 +167,12 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
{ /* Text + Icon Row */ } { /* Text + Icon Row */ }
<div className="flex gap-2"> <div className="flex gap-2">
<div className="flex flex-col gap-0.5 flex-1"> <div className="flex flex-col gap-0.5 flex-1">
<label className="text-[11px] font-bold uppercase tracking-wider opacity-60">Text</label> <label className="text-[11px] font-bold uppercase tracking-wider opacity-60">{ LocalizeText('catalog.prefix.text') }</label>
<div className="relative"> <div className="relative">
<input <input
className="w-full px-3 py-1.5 rounded-md text-sm focus:outline-none transition-all" className="w-full px-3 py-1.5 rounded-md text-sm focus:outline-none transition-all"
maxLength={ 15 } maxLength={ 15 }
placeholder="Enter text..." placeholder={ LocalizeText('catalog.prefix.text.placeholder') }
style={ { style={ {
background: 'rgba(0,0,0,0.15)', background: 'rgba(0,0,0,0.15)',
border: '1px solid rgba(0,0,0,0.15)', border: '1px solid rgba(0,0,0,0.15)',
@@ -214,7 +187,7 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
</div> </div>
</div> </div>
<div className="flex flex-col gap-0.5 relative"> <div className="flex flex-col gap-0.5 relative">
<label className="text-[11px] font-bold uppercase tracking-wider opacity-60">Icon</label> <label className="text-[11px] font-bold uppercase tracking-wider opacity-60">{ LocalizeText('catalog.prefix.icon') }</label>
<div className="flex gap-1"> <div className="flex gap-1">
<button <button
className="flex items-center justify-center gap-1 px-3 py-1.5 rounded-md text-sm transition-all min-w-[70px]" className="flex items-center justify-center gap-1 px-3 py-1.5 rounded-md text-sm transition-all min-w-[70px]"
@@ -232,7 +205,7 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
<button <button
className="flex items-center justify-center px-1.5 rounded-md text-xs transition-all" 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)' } } style={ { background: 'rgba(239,68,68,0.15)', border: '1px solid rgba(239,68,68,0.3)' } }
title="Remove icon" title={ LocalizeText('catalog.prefix.icon.remove') }
onClick={ () => setSelectedIcon('') }> onClick={ () => setSelectedIcon('') }>
</button> </button>
@@ -241,14 +214,14 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
</div> </div>
</div> </div>
{ /* Emoji Picker (emoji-mart) - portaled to body, no backdrop */ } { /* Emoji Picker (emoji-mart) - fixed overlay */ }
{ showIconPicker && createPortal( { showIconPicker && (
<> <>
<div className="fixed inset-0" style={ { zIndex: 9998 } } onClick={ () => setShowIconPicker(false) } /> <div className="fixed inset-0" style={ { zIndex: 999, background: 'rgba(0,0,0,0.5)' } } onClick={ () => setShowIconPicker(false) } />
<div ref={ pickerContainerRef } className="fixed rounded-xl overflow-hidden" style={ { zIndex: 9999, top: '50%', left: '50%', transform: 'translate(-50%, -50%)', background: '#2b2f35' } }> <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 <Picker
data={ data } data={ data }
locale="en" locale="it"
onEmojiSelect={ (emoji: { native: string }) => { setSelectedIcon(emoji.native); setShowIconPicker(false); } } onEmojiSelect={ (emoji: { native: string }) => { setSelectedIcon(emoji.native); setShowIconPicker(false); } }
theme="dark" theme="dark"
previewPosition="none" previewPosition="none"
@@ -261,13 +234,12 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
set="native" set="native"
/> />
</div> </div>
</>, </>
document.body
) } ) }
{ /* Effect Selector */ } { /* Effect Selector */ }
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-[11px] font-bold uppercase tracking-wider opacity-60">Effect</label> <label className="text-[11px] font-bold uppercase tracking-wider opacity-60">{ LocalizeText('catalog.prefix.effect') }</label>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{ PRESET_PREFIX_EFFECTS.map(fx => ( { PRESET_PREFIX_EFFECTS.map(fx => (
<button <button
@@ -287,7 +259,7 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
{ /* Color Mode Toggle */ } { /* Color Mode Toggle */ }
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-[11px] font-bold uppercase tracking-wider opacity-60">Color</label> <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)' } }> <div className="flex rounded-md overflow-hidden" style={ { border: '1px solid rgba(0,0,0,0.15)' } }>
<button <button
className="flex-1 px-2 py-1.5 text-xs font-bold transition-all" className="flex-1 px-2 py-1.5 text-xs font-bold transition-all"
@@ -297,7 +269,7 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
opacity: colorMode === 'single' ? 1 : 0.6 opacity: colorMode === 'single' ? 1 : 0.6
} } } }
onClick={ () => { setColorMode('single'); setSelectedLetterIndex(null); } }> onClick={ () => { setColorMode('single'); setSelectedLetterIndex(null); } }>
🎨 Single { LocalizeText('catalog.prefix.color.single') }
</button> </button>
<button <button
className="flex-1 px-2 py-1.5 text-xs font-bold transition-all" className="flex-1 px-2 py-1.5 text-xs font-bold transition-all"
@@ -306,7 +278,7 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
opacity: colorMode === 'perLetter' ? 1 : 0.6 opacity: colorMode === 'perLetter' ? 1 : 0.6
} } } }
onClick={ () => { setColorMode('perLetter'); if(prefixText.length > 0) setSelectedLetterIndex(0); } }> onClick={ () => { setColorMode('perLetter'); if(prefixText.length > 0) setSelectedLetterIndex(0); } }>
🌈 Per Letter { LocalizeText('catalog.prefix.color.per.letter') }
</button> </button>
</div> </div>
</div> </div>
@@ -316,7 +288,7 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-[10px] opacity-50"> <span className="text-[10px] opacity-50">
Select a letter, then choose a color. Auto-advances. { LocalizeText('catalog.prefix.color.hint') }
</span> </span>
<button <button
className="text-[10px] px-1.5 py-0.5 rounded transition-all" className="text-[10px] px-1.5 py-0.5 rounded transition-all"
@@ -324,9 +296,9 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
background: 'rgba(0,0,0,0.1)', background: 'rgba(0,0,0,0.1)',
border: '1px solid rgba(0,0,0,0.1)' border: '1px solid rgba(0,0,0,0.1)'
} } } }
title="Apply current color to all letters" title={ LocalizeText('catalog.prefix.color.apply.all.title') }
onClick={ applyColorToAll }> onClick={ applyColorToAll }>
Apply to all { LocalizeText('catalog.prefix.color.apply.all') }
</button> </button>
</div> </div>
<div className="flex flex-wrap gap-1 p-2 rounded-lg" <div className="flex flex-wrap gap-1 p-2 rounded-lg"
@@ -379,25 +351,20 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{ colorMode === 'perLetter' && selectedLetterIndex !== null && { colorMode === 'perLetter' && selectedLetterIndex !== null &&
<span className="text-[10px] opacity-50 italic"> <span className="text-[10px] opacity-50 italic">
Selected letter: &quot;{ prefixText[selectedLetterIndex] || '' }&quot; { LocalizeText('catalog.prefix.color.selected') } &quot;{ prefixText[selectedLetterIndex] || '' }&quot;
</span> </span>
} }
<div className="grid grid-cols-10 gap-[3px]"> <div className="grid gap-1" style={ { gridTemplateColumns: 'repeat(auto-fill, minmax(34px, 1fr))' } }>
{ PRESET_COLORS.map((color, idx) => { PRESET_COLORS.map((color, idx) =>
{ {
const isActive = currentActiveColor === color; const isActive = currentActiveColor === color;
return ( return (
<div <div
key={ idx } key={ idx }
className="cursor-pointer transition-all" 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={ { style={ {
width: '100%',
aspectRatio: '1',
borderRadius: '5px',
backgroundColor: color, backgroundColor: color,
border: isActive ? '2px solid #fff' : '1px solid rgba(0,0,0,0.15)', 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)',
boxShadow: isActive ? `0 0 6px ${ color }, 0 0 0 1px rgba(0,0,0,0.2)` : 'inset 0 1px 0 rgba(255,255,255,0.2)',
transform: isActive ? 'scale(1.2)' : 'scale(1)',
zIndex: isActive ? 5 : 1 zIndex: isActive ? 5 : 1
} } } }
onClick={ () => handleColorSelect(color) } /> onClick={ () => handleColorSelect(color) } />
@@ -443,8 +410,8 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
<div className="flex items-center justify-between mt-auto pt-2" <div className="flex items-center justify-between mt-auto pt-2"
style={ { borderTop: '1px solid rgba(0,0,0,0.1)' } }> style={ { borderTop: '1px solid rgba(0,0,0,0.1)' } }>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="text-xs opacity-60">Price:</span> <span className="text-xs opacity-60">{ LocalizeText('catalog.prefix.price') }</span>
<span className="text-sm font-bold">5 Credits</span> <span className="text-sm font-bold">{ LocalizeText('catalog.prefix.price.amount') }</span>
</div> </div>
<button <button
className="px-5 py-1.5 rounded-md text-sm font-bold transition-all" className="px-5 py-1.5 rounded-md text-sm font-bold transition-all"
@@ -462,7 +429,7 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
borderRadius: '6px' borderRadius: '6px'
} } } }
onClick={ handlePurchase }> onClick={ handlePurchase }>
{ purchased ? '✓ Purchased!' : 'Purchase' } { purchased ? LocalizeText('catalog.prefix.purchased') : LocalizeText('catalog.prefix.purchase') }
</button> </button>
</div> </div>
</div> </div>
@@ -1,7 +1,9 @@
import { FC } from 'react'; import { FC } from 'react';
import { GetConfigurationValue, ProductTypeEnum } from '../../../../../api'; import { FaEdit, FaPlus } from 'react-icons/fa';
import { Column, Flex, Grid, LayoutImage, Text } from '../../../../../common'; import { GetConfigurationValue, LocalizeText, ProductTypeEnum } from '../../../../../api';
import { Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks'; import { useCatalog } from '../../../../../hooks';
import { useCatalogAdmin } from '../../../CatalogAdminContext';
import { CatalogHeaderView } from '../../catalog-header/CatalogHeaderView'; import { CatalogHeaderView } from '../../catalog-header/CatalogHeaderView';
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView'; import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView'; import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView';
@@ -16,46 +18,87 @@ export const CatalogLayoutDefaultView: FC<CatalogLayoutProps> = props =>
{ {
const { page = null } = props; const { page = null } = props;
const { currentOffer = null, currentPage = null } = useCatalog(); const { currentOffer = null, currentPage = null } = useCatalog();
const catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
return ( return (
<> <div className="flex flex-col h-full gap-2">
<Grid> { /* Admin: quick actions */ }
<Column overflow="hidden" size={ 7 }> { adminMode && !catalogAdmin.editingPageData &&
{ GetConfigurationValue('catalog.headers') && <div className="flex gap-2">
<CatalogHeaderView imageUrl={ currentPage.localization.getImage(0) } /> } <button
<CatalogItemGridWidgetView /> className="flex items-center gap-1 text-[10px] text-primary hover:text-dark transition-colors cursor-pointer"
</Column> onClick={ () => { catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true); } }
<Column center={ !currentOffer } overflow="hidden" size={ 5 }> >
{ !currentOffer && <FaEdit className="text-[10px]" /> { LocalizeText('catalog.admin.edit.page') }
<> </button>
{ !!page.localization.getImage(1) && <button
<LayoutImage imageUrl={ page.localization.getImage(1) } /> } className="flex items-center gap-1 text-[10px] text-success hover:text-green-800 transition-colors cursor-pointer"
<Text center dangerouslySetInnerHTML={ { __html: page.localization.getText(0) } } /> onClick={ () => catalogAdmin.setEditingOffer({ offerId: -1, product: { productClassId: 0, productType: 'i', productCount: 1, extraParam: '' } } as any) }
</> } >
{ currentOffer && <FaPlus className="text-[10px]" /> { LocalizeText('catalog.admin.offer.new') }
<> </button>
<Flex center overflow="hidden" style={ { height: 140 } }> </div> }
{ (currentOffer.product.productType !== ProductTypeEnum.BADGE) &&
<> { /* Product detail card */ }
<CatalogViewProductWidgetView /> { currentOffer &&
<CatalogAddOnBadgeWidgetView className="bg-muted rounded bottom-1 inset-e-1" /> <div className="flex gap-0 bg-white rounded border-2 border-card-grid-item-border overflow-hidden">
</> } { /* Preview area */ }
{ (currentOffer.product.productType === ProductTypeEnum.BADGE) && <CatalogAddOnBadgeWidgetView className="scale-2" /> } <div className="w-[140px] min-w-[140px] bg-card-grid-item relative flex items-center justify-center border-r-2 border-card-grid-item-border">
</Flex> { (currentOffer.product.productType !== ProductTypeEnum.BADGE) &&
<Column grow gap={ 1 }> <>
<CatalogLimitedItemWidgetView /> <CatalogViewProductWidgetView />
<Text grow truncate>{ currentOffer.localizationName }</Text> <CatalogAddOnBadgeWidgetView className="bg-muted rounded bottom-1 right-1 absolute" />
<div className="flex justify-between"> </> }
<div className="flex flex-col gap-1"> { (currentOffer.product.productType === ProductTypeEnum.BADGE) &&
<CatalogSpinnerWidgetView /> <CatalogAddOnBadgeWidgetView className="scale-2" /> }
</div> </div>
<CatalogTotalPriceWidget alignItems="end" justifyContent="end" /> { /* Product info + purchase */ }
</div> <div className="flex flex-col flex-1 min-w-0 p-2.5 gap-2">
<CatalogPurchaseWidgetView /> { /* Title row */ }
</Column> <div>
</> } <div className="flex items-start justify-between gap-2">
</Column> <Text className="text-[13px]! font-bold text-dark leading-tight">{ currentOffer.localizationName }</Text>
</Grid> { 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>
{ /* Price */ }
<CatalogTotalPriceWidget />
{ /* Spinner */ }
<CatalogSpinnerWidgetView />
{ /* Actions */ }
<div className="flex gap-1.5 mt-auto">
<CatalogPurchaseWidgetView />
</div>
</div>
</div> }
{ /* Welcome/description card */ }
{ !currentOffer &&
<div className="flex items-center gap-3 p-2.5 bg-white rounded border-2 border-card-grid-item-border">
{ !!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: page.localization.getText(0) } } />
</div> }
{ /* Item grid */ }
<div className="flex-1 overflow-auto min-h-0">
{ GetConfigurationValue('catalog.headers') &&
<CatalogHeaderView imageUrl={ currentPage.localization.getImage(0) } /> }
<CatalogItemGridWidgetView columnCount={ 7 } columnMinHeight={ 50 } columnMinWidth={ 50 } />
</div>
</div>
); );
}; };
@@ -1,5 +1,5 @@
import { FC } from 'react'; import { FC } from 'react';
import { Column } from '../../../../../common'; import { FaPaw } from 'react-icons/fa';
import { CatalogLayoutProps } from './CatalogLayout.types'; import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayoutPets3View: FC<CatalogLayoutProps> = props => export const CatalogLayoutPets3View: FC<CatalogLayoutProps> = props =>
@@ -9,17 +9,28 @@ export const CatalogLayoutPets3View: FC<CatalogLayoutProps> = props =>
const imageUrl = page.localization.getImage(1); const imageUrl = page.localization.getImage(1);
return ( return (
<Column grow className="bg-muted rounded text-black p-2" overflow="hidden"> <div className="flex flex-col h-full gap-2">
<div className="items-center gap-2"> { /* Header card */ }
{ imageUrl && <img alt="" src={ imageUrl } /> } <div className="flex items-center gap-3 p-2.5 bg-white rounded border-2 border-card-grid-item-border">
<div className="fs-5" dangerouslySetInnerHTML={ { __html: page.localization.getText(1) } } /> { 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: page.localization.getText(1) } } />
</div>
</div>
</div> </div>
<Column grow alignItems="center" overflow="auto">
<div dangerouslySetInnerHTML={ { __html: page.localization.getText(2) } } /> { /* Content */ }
</Column> <div className="flex-1 overflow-auto bg-white rounded border-2 border-card-grid-item-border p-3">
<div className="flex items-center"> <div className="text-[11px] leading-relaxed" dangerouslySetInnerHTML={ { __html: page.localization.getText(2) } } />
<div className="font-bold " dangerouslySetInnerHTML={ { __html: page.localization.getText(3) } } />
</div> </div>
</Column>
{ /* 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: page.localization.getText(3) } } />
</div> }
</div>
); );
}; };
@@ -1,6 +1,10 @@
import { FC, useEffect, useState } from 'react'; import { FC, useEffect, useState } from 'react';
import { Column, Grid, Text } from '../../../../../common'; import { FaEdit, FaPen, FaPlus, FaTrophy } from 'react-icons/fa';
import { LocalizeText, ProductTypeEnum } from '../../../../../api';
import { Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks'; import { useCatalog } from '../../../../../hooks';
import { useCatalogAdmin } from '../../../CatalogAdminContext';
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView'; import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView';
import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView'; import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView';
import { CatalogTotalPriceWidget } from '../widgets/CatalogTotalPriceWidget'; import { CatalogTotalPriceWidget } from '../widgets/CatalogTotalPriceWidget';
@@ -12,6 +16,8 @@ export const CatalogLayoutTrophiesView: FC<CatalogLayoutProps> = props =>
const { page = null } = props; const { page = null } = props;
const [ trophyText, setTrophyText ] = useState<string>(''); const [ trophyText, setTrophyText ] = useState<string>('');
const { currentOffer = null, setPurchaseOptions = null } = useCatalog(); const { currentOffer = null, setPurchaseOptions = null } = useCatalog();
const catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
useEffect(() => useEffect(() =>
{ {
@@ -27,30 +33,104 @@ export const CatalogLayoutTrophiesView: FC<CatalogLayoutProps> = props =>
}); });
}, [ currentOffer, trophyText, setPurchaseOptions ]); }, [ currentOffer, trophyText, setPurchaseOptions ]);
const canPurchase = currentOffer && trophyText.trim().length > 0;
return ( return (
<Grid> <div className="flex flex-col h-full gap-2">
<Column overflow="hidden" size={ 7 }> { /* Admin: quick actions */ }
<CatalogItemGridWidgetView /> { adminMode && !catalogAdmin.editingPageData &&
<textarea className="grow! form-control w-full" defaultValue={ trophyText || '' } onChange={ event => setTrophyText(event.target.value) } /> <div className="flex gap-2">
</Column> <button
<Column center={ !currentOffer } overflow="hidden" size={ 5 }> className="flex items-center gap-1 text-[10px] text-primary hover:text-dark transition-colors cursor-pointer"
{ !currentOffer && onClick={ () => { catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true); } }
<> >
{ !!page.localization.getImage(1) && <img alt="" src={ page.localization.getImage(1) } /> } <FaEdit className="text-[10px]" /> { LocalizeText('catalog.admin.edit.page') }
<Text center dangerouslySetInnerHTML={ { __html: page.localization.getText(0) } } /> </button>
</> } <button
{ currentOffer && 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) }
<CatalogViewProductWidgetView /> >
<Column grow gap={ 1 }> <FaPlus className="text-[10px]" /> { LocalizeText('catalog.admin.offer.new') }
<Text grow truncate>{ currentOffer.localizationName }</Text> </button>
<div className="flex justify-end"> </div> }
<CatalogTotalPriceWidget alignItems="end" />
</div> { /* Selected trophy card */ }
{ currentOffer
? <div className="flex gap-0 bg-white rounded border-2 border-warning/40 overflow-hidden" 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 mt-auto">
<CatalogPurchaseWidgetView /> <CatalogPurchaseWidgetView />
</Column> </div>
</> } </div>
</Column> </div>
</Grid> : <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: 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>
); );
}; };
@@ -1,12 +1,12 @@
import { ApproveNameMessageComposer, ApproveNameMessageEvent, ColorConverter, GetSellablePetPalettesComposer, PurchaseFromCatalogComposer, SellablePetPaletteData } from '@nitrots/nitro-renderer'; import { ApproveNameMessageComposer, ApproveNameMessageEvent, ColorConverter, GetSellablePetPalettesComposer, PurchaseFromCatalogComposer, SellablePetPaletteData } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { FaFillDrip } from 'react-icons/fa'; import { FaCheck, FaEdit, FaFillDrip, FaPaw, FaPlus, FaTimes } from 'react-icons/fa';
import { DispatchUiEvent, GetPetAvailableColors, GetPetIndexFromLocalization, LocalizeText, SendMessageComposer } from '../../../../../../api'; import { DispatchUiEvent, GetPetAvailableColors, GetPetIndexFromLocalization, LocalizeText, SendMessageComposer } from '../../../../../../api';
import { AutoGrid, Button, Column, Grid, LayoutGridItem, LayoutPetImageView, Text } from '../../../../../../common'; import { LayoutGridItem, LayoutPetImageView } from '../../../../../../common';
import { CatalogPurchaseFailureEvent } from '../../../../../../events'; import { CatalogPurchaseFailureEvent } from '../../../../../../events';
import { useCatalog, useMessageEvent } from '../../../../../../hooks'; import { useCatalog, useMessageEvent } from '../../../../../../hooks';
import { useCatalogAdmin } from '../../../../CatalogAdminContext';
import { CatalogAddOnBadgeWidgetView } from '../../widgets/CatalogAddOnBadgeWidgetView'; import { CatalogAddOnBadgeWidgetView } from '../../widgets/CatalogAddOnBadgeWidgetView';
import { CatalogPurchaseWidgetView } from '../../widgets/CatalogPurchaseWidgetView';
import { CatalogTotalPriceWidget } from '../../widgets/CatalogTotalPriceWidget'; import { CatalogTotalPriceWidget } from '../../widgets/CatalogTotalPriceWidget';
import { CatalogViewProductWidgetView } from '../../widgets/CatalogViewProductWidgetView'; import { CatalogViewProductWidgetView } from '../../widgets/CatalogViewProductWidgetView';
import { CatalogLayoutProps } from '../CatalogLayout.types'; import { CatalogLayoutProps } from '../CatalogLayout.types';
@@ -24,6 +24,8 @@ export const CatalogLayoutPetView: FC<CatalogLayoutProps> = props =>
const [ approvalPending, setApprovalPending ] = useState(true); const [ approvalPending, setApprovalPending ] = useState(true);
const [ approvalResult, setApprovalResult ] = useState(-1); const [ approvalResult, setApprovalResult ] = useState(-1);
const { currentOffer = null, setCurrentOffer = null, setPurchaseOptions = null, catalogOptions = null, roomPreviewer = null } = useCatalog(); const { currentOffer = null, setCurrentOffer = null, setPurchaseOptions = null, catalogOptions = null, roomPreviewer = null } = useCatalog();
const catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
const { petPalettes = null } = catalogOptions; const { petPalettes = null } = catalogOptions;
const getColor = useMemo(() => const getColor = useMemo(() =>
@@ -194,50 +196,131 @@ export const CatalogLayoutPetView: FC<CatalogLayoutProps> = props =>
if(!currentOffer) return null; if(!currentOffer) return null;
return ( return (
<Grid> <div className="flex flex-col h-full gap-2">
<Column overflow="hidden" size={ 7 }> { /* Admin: quick actions */ }
<AutoGrid columnCount={ 5 }> { adminMode && !catalogAdmin.editingPageData &&
{ !colorsShowing && (sellablePalettes.length > 0) && sellablePalettes.map((palette, index) => <div className="flex gap-2">
{ <button
return ( className="flex items-center gap-1 text-[10px] text-primary hover:text-dark transition-colors cursor-pointer"
<LayoutGridItem key={ index } itemActive={ (selectedPaletteIndex === index) } onClick={ event => setSelectedPaletteIndex(index) }> onClick={ () => { catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true); } }
<LayoutPetImageView direction={ 2 } headOnly={ true } paletteId={ palette.paletteId } typeId={ petIndex } /> >
</LayoutGridItem> <FaEdit className="text-[10px]" /> { LocalizeText('catalog.admin.edit.page') }
); </button>
}) } <button
{ colorsShowing && (sellableColors.length > 0) && sellableColors.map((colorSet, index) => <LayoutGridItem key={ index } itemHighlight className="clear-bg" itemActive={ (selectedColorIndex === index) } itemColor={ ColorConverter.int2rgb(colorSet[0]) } onClick={ event => setSelectedColorIndex(index) } />) } className="flex items-center gap-1 text-[10px] text-success hover:text-green-800 transition-colors cursor-pointer"
</AutoGrid> onClick={ () => catalogAdmin.setEditingOffer({ offerId: -1, product: { productClassId: 0, productType: 'i', productCount: 1, extraParam: '' } } as any) }
</Column> >
<Column center={ !currentOffer } overflow="hidden" size={ 5 }> <FaPlus className="text-[10px]" /> { LocalizeText('catalog.admin.offer.new') }
{ !currentOffer && </button>
<> </div> }
{ !!page.localization.getImage(1) && <img alt="" src={ page.localization.getImage(1) } /> }
<Text center dangerouslySetInnerHTML={ { __html: page.localization.getText(0) } } /> { /* Top card: preview + name + purchase */ }
</> } <div className="flex gap-3 p-2.5 bg-white rounded border-2 border-card-grid-item-border">
{ currentOffer && { /* 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">
<div className="relative overflow-hidden"> <CatalogViewProductWidgetView />
<CatalogViewProductWidgetView /> <CatalogAddOnBadgeWidgetView className="bg-muted rounded absolute bottom-1 right-1" />
<CatalogAddOnBadgeWidgetView className="bg-muted rounded bottom-1 inset-e-1" position="absolute" /> { ((petIndex > -1) && (petIndex <= 7)) &&
{ ((petIndex > -1) && (petIndex <= 7)) && <button
<Button className="bottom-1 inset-s-1" position="absolute" onClick={ event => setColorsShowing(!colorsShowing) }> 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' }` }
<FaFillDrip className="fa-icon" /> title={ LocalizeText('catalog.pets.show.colors') }
</Button> } 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> </div>
<Column grow gap={ 1 }> { adminMode && currentOffer &&
<Text truncate>{ petBreedName }</Text> <div className="flex items-center gap-1 mt-0.5 flex-wrap">
<Column grow gap={ 1 }> <span className="text-[8px] font-mono text-white bg-gray-600 px-1 py-px rounded">ID: { currentOffer.product.productClassId }</span>
<input className="min-h-[calc(1.5em+ .5rem+2px)] px-[.5rem] py-[.25rem] rounded-[.2rem] form-control-sm w-full" placeholder={ LocalizeText('widgets.petpackage.name.title') } type="text" value={ petName } onChange={ event => setPetName(event.target.value) } /> <span className="text-[8px] font-mono text-white bg-primary px-1 py-px rounded">Offer: { currentOffer.offerId }</span>
{ (approvalResult > 0) && </div> }
<div className="invalid-feedback d-block m-0">{ validationErrorMessage }</div> } { !!page.localization.getText(0) &&
</Column> <p className="text-[10px] text-muted mt-0.5" dangerouslySetInnerHTML={ { __html: page.localization.getText(0) } } /> }
<div className="flex justify-end"> </div>
<CatalogTotalPriceWidget alignItems="end" justifyContent="end" />
</div> { /* Name input */ }
<CatalogPurchaseWidgetView purchaseCallback={ purchasePet } /> <div className="flex flex-col gap-1 mt-2">
</Column> <label className="text-[9px] text-muted uppercase font-bold">{ LocalizeText('widgets.petpackage.name.title') }</label>
</> } <div className="relative">
</Column> <input
</Grid> 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>
); );
}; };
@@ -1,7 +1,8 @@
import { FC, useEffect, useRef } from 'react'; import { FC, useCallback, useEffect, useRef, useState } from 'react';
import { IPurchasableOffer, ProductTypeEnum } from '../../../../../api'; import { IPurchasableOffer, ProductTypeEnum } from '../../../../../api';
import { AutoGrid, AutoGridProps } from '../../../../../common'; import { AutoGrid, AutoGridProps } from '../../../../../common';
import { useCatalog } from '../../../../../hooks'; import { useCatalog } from '../../../../../hooks';
import { useCatalogAdmin } from '../../../CatalogAdminContext';
import { CatalogGridOfferView } from '../common/CatalogGridOfferView'; import { CatalogGridOfferView } from '../common/CatalogGridOfferView';
interface CatalogItemGridWidgetViewProps extends AutoGridProps interface CatalogItemGridWidgetViewProps extends AutoGridProps
@@ -13,7 +14,11 @@ export const CatalogItemGridWidgetView: FC<CatalogItemGridWidgetViewProps> = pro
{ {
const { columnCount = 5, children = null, ...rest } = props; const { columnCount = 5, children = null, ...rest } = props;
const { currentOffer = null, setCurrentOffer = null, currentPage = null, setPurchaseOptions = null } = useCatalog(); const { currentOffer = null, setCurrentOffer = null, currentPage = null, setPurchaseOptions = null } = useCatalog();
const catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
const elementRef = useRef<HTMLDivElement>(); const elementRef = useRef<HTMLDivElement>();
const [ dragIndex, setDragIndex ] = useState<number | null>(null);
const [ dropIndex, setDropIndex ] = useState<number | null>(null);
useEffect(() => useEffect(() =>
{ {
@@ -43,9 +48,66 @@ export const CatalogItemGridWidgetView: FC<CatalogItemGridWidgetViewProps> = pro
} }
}; };
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);
}, []);
return ( return (
<AutoGrid columnCount={ columnCount } innerRef={ elementRef } { ...rest }> <AutoGrid columnCount={ columnCount } innerRef={ elementRef } { ...rest }>
{ currentPage.offers && (currentPage.offers.length > 0) && currentPage.offers.map((offer, index) => <CatalogGridOfferView key={ index } itemActive={ (currentOffer && (currentOffer.offerId === offer.offerId)) } offer={ offer } selectOffer={ selectOffer } />) } { 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 } { children }
</AutoGrid> </AutoGrid>
); );
@@ -19,19 +19,19 @@ export const CatalogPriceDisplayWidgetView: FC<CatalogPriceDisplayWidgetViewProp
if(!offer) return null; if(!offer) return null;
return ( return (
<> <div className="flex items-center gap-1.5">
{ (offer.priceInCredits > 0) && { (offer.priceInCredits > 0) &&
<div className="flex items-center gap-1"> <div className="flex items-center gap-1 bg-warning/15 border border-warning/40 rounded-full px-2 py-0.5">
<Text bold>{ (offer.priceInCredits * quantity) }</Text> <Text className="text-[11px]! font-bold text-dark">{ (offer.priceInCredits * quantity) }</Text>
<LayoutCurrencyIcon type={ -1 } /> <LayoutCurrencyIcon type={ -1 } />
</div> } </div> }
{ separator && (offer.priceInCredits > 0) && (offer.priceInActivityPoints > 0) && { separator && (offer.priceInCredits > 0) && (offer.priceInActivityPoints > 0) &&
<FaPlus className="fa-icon" color="black" size="xs" /> } <FaPlus className="text-[7px] text-muted" /> }
{ (offer.priceInActivityPoints > 0) && { (offer.priceInActivityPoints > 0) &&
<div className="flex items-center gap-1"> <div className="flex items-center gap-1 bg-purple/15 border border-purple/40 rounded-full px-2 py-0.5">
<Text bold>{ (offer.priceInActivityPoints * quantity) }</Text> <Text className="text-[11px]! font-bold text-dark">{ (offer.priceInActivityPoints * quantity) }</Text>
<LayoutCurrencyIcon type={ offer.activityPointType } /> <LayoutCurrencyIcon type={ offer.activityPointType } />
</div> } </div> }
</> </div>
); );
}; };
@@ -1,11 +1,10 @@
import { FC } from 'react'; import { FC } from 'react';
import { FaCaretLeft, FaCaretRight } from 'react-icons/fa'; import { FaMinus, FaPlus } from 'react-icons/fa';
import { LocalizeText } from '../../../../../api'; import { LocalizeText } from '../../../../../api';
import { Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks'; import { useCatalog } from '../../../../../hooks';
const MIN_VALUE: number = 1; const MIN_VALUE: number = 1;
const MAX_VALUE: number = 100; const MAX_VALUE: number = 99;
export const CatalogSpinnerWidgetView: FC<{}> = props => export const CatalogSpinnerWidgetView: FC<{}> = props =>
{ {
@@ -34,13 +33,28 @@ export const CatalogSpinnerWidgetView: FC<{}> = props =>
if(!currentOffer || !currentOffer.bundlePurchaseAllowed) return null; if(!currentOffer || !currentOffer.bundlePurchaseAllowed) return null;
return ( return (
<> <div className="flex items-center gap-1.5">
<Text>{ LocalizeText('catalog.bundlewidget.spinner.select.amount') }</Text> <span className="text-[10px] text-muted whitespace-nowrap">{ LocalizeText('catalog.bundlewidget.spinner.select.amount') }</span>
<div className="flex items-center gap-1"> <div className="flex items-center rounded overflow-hidden border-2 border-card-grid-item-border">
<FaCaretLeft className="text-black cursor-pointer fa-icon" onClick={ event => updateQuantity(quantity - 1) } /> <button
<input className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none min-h-[17px] h-[17px] w-[28px] px-[4px] py-0 text-right rounded-[.2rem]" type="number" value={ quantity } onChange={ event => updateQuantity(event.target.valueAsNumber) } /> 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"
<FaCaretRight className="text-black cursor-pointer fa-icon" onClick={ event => updateQuantity(quantity + 1) } /> 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>
</> </div>
); );
}; };
+41 -11
View File
@@ -1,30 +1,44 @@
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; import { AddLinkEventTracker, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useState } from 'react'; import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common'; import { NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common';
import { useFurniEditor } from '../../hooks/furni-editor'; import { useFurniEditor } from '../../hooks/furni-editor';
import { FurniEditorCreateView } from './views/FurniEditorCreateView';
import { FurniEditorEditView } from './views/FurniEditorEditView'; import { FurniEditorEditView } from './views/FurniEditorEditView';
import { FurniEditorSearchView } from './views/FurniEditorSearchView'; import { FurniEditorSearchView } from './views/FurniEditorSearchView';
const TAB_SEARCH = 0; const TAB_SEARCH = 0;
const TAB_EDIT = 1; const TAB_EDIT = 1;
const TAB_CREATE = 2;
export const FurniEditorView: FC<{}> = () => export const FurniEditorView: FC<{}> = () =>
{ {
const [ isVisible, setIsVisible ] = useState(false); const [ isVisible, setIsVisible ] = useState(false);
const [ activeTab, setActiveTab ] = useState(TAB_SEARCH); const [ activeTab, setActiveTab ] = useState(TAB_SEARCH);
const pendingEditRef = useRef(false);
const { const {
items, total, page, loading, error, clearError, items, total, page, loading, error, clearError,
selectedItem, catalogItems, furniDataEntry, selectedItem, catalogItems, furniDataEntry,
interactions, interactions,
searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions searchItems, loadDetail, loadBySpriteId, updateItem, createItem, deleteItem, loadInteractions
} = useFurniEditor(); } = useFurniEditor();
useEffect(() =>
{
if(selectedItem && pendingEditRef.current)
{
pendingEditRef.current = false;
setActiveTab(TAB_EDIT);
}
}, [ selectedItem ]);
useEffect(() => useEffect(() =>
{ {
const linkTracker: ILinkEventTracker = { const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) => linkReceived: (url: string) =>
{ {
if(!GetSessionDataManager().isModerator) return;
const parts = url.split('/'); const parts = url.split('/');
if(parts.length < 2) return; if(parts.length < 2) return;
@@ -57,13 +71,16 @@ export const FurniEditorView: FC<{}> = () =>
useEffect(() => useEffect(() =>
{ {
const handler = async (e: CustomEvent<{ spriteId: number }>) => const handler = (e: CustomEvent<{ spriteId: number }>) =>
{ {
if(!GetSessionDataManager().isModerator) return;
const { spriteId } = e.detail; const { spriteId } = e.detail;
const ok = await loadBySpriteId(spriteId); if(!Number.isFinite(spriteId) || spriteId < 0) return;
if(ok) setActiveTab(TAB_EDIT); pendingEditRef.current = true;
loadBySpriteId(spriteId);
}; };
window.addEventListener('furni-editor:open', handler as EventListener); window.addEventListener('furni-editor:open', handler as EventListener);
@@ -71,11 +88,10 @@ export const FurniEditorView: FC<{}> = () =>
return () => window.removeEventListener('furni-editor:open', handler as EventListener); return () => window.removeEventListener('furni-editor:open', handler as EventListener);
}, [ loadBySpriteId ]); }, [ loadBySpriteId ]);
const handleSelect = useCallback(async (id: number) => const handleSelect = useCallback((id: number) =>
{ {
const ok = await loadDetail(id); pendingEditRef.current = true;
loadDetail(id);
if(ok) setActiveTab(TAB_EDIT);
}, [ loadDetail ]); }, [ loadDetail ]);
const handleBack = useCallback(() => const handleBack = useCallback(() =>
@@ -88,7 +104,9 @@ export const FurniEditorView: FC<{}> = () =>
setIsVisible(false); setIsVisible(false);
}, []); }, []);
if(!isVisible) return null; const isMod = useMemo(() => GetSessionDataManager().isModerator, []);
if(!isVisible || !isMod) return null;
return ( return (
<NitroCardView uniqueKey="furni-editor" className="w-[620px] h-[520px]"> <NitroCardView uniqueKey="furni-editor" className="w-[620px] h-[520px]">
@@ -100,6 +118,9 @@ export const FurniEditorView: FC<{}> = () =>
<NitroCardTabsItemView isActive={ activeTab === TAB_EDIT } onClick={ () => selectedItem && setActiveTab(TAB_EDIT) }> <NitroCardTabsItemView isActive={ activeTab === TAB_EDIT } onClick={ () => selectedItem && setActiveTab(TAB_EDIT) }>
Edit Edit
</NitroCardTabsItemView> </NitroCardTabsItemView>
<NitroCardTabsItemView isActive={ activeTab === TAB_CREATE } onClick={ () => setActiveTab(TAB_CREATE) }>
Create
</NitroCardTabsItemView>
</NitroCardTabsView> </NitroCardTabsView>
<NitroCardContentView> <NitroCardContentView>
{ error && { error &&
@@ -134,6 +155,15 @@ export const FurniEditorView: FC<{}> = () =>
/> />
} }
{ activeTab === TAB_CREATE &&
<FurniEditorCreateView
interactions={ interactions }
loading={ loading }
onCreate={ createItem }
onBack={ handleBack }
/>
}
</NitroCardContentView> </NitroCardContentView>
</NitroCardView> </NitroCardView>
); );
@@ -5,14 +5,13 @@ interface FurniEditorCreateViewProps
{ {
interactions: string[]; interactions: string[];
loading: boolean; loading: boolean;
onCreate: (fields: Record<string, unknown>) => Promise<number | null>; onCreate: (fields: Record<string, unknown>) => void;
onCreated: (id: number) => void; onBack: () => void;
} }
export const FurniEditorCreateView: FC<FurniEditorCreateViewProps> = props => export const FurniEditorCreateView: FC<FurniEditorCreateViewProps> = props =>
{ {
const { interactions, loading, onCreate, onCreated } = props; const { interactions, loading, onCreate, onBack } = props;
const [ success, setSuccess ] = useState<number | null>(null);
const [ form, setForm ] = useState({ const [ form, setForm ] = useState({
itemName: '', itemName: '',
@@ -34,37 +33,42 @@ export const FurniEditorCreateView: FC<FurniEditorCreateViewProps> = props =>
interactionType: '', interactionType: '',
interactionModesCount: 1, interactionModesCount: 1,
customparams: '', customparams: '',
description: '',
revision: 0,
category: '',
defaultdir: 0,
offerid: 0,
buyout: false,
rentofferid: 0,
rentbuyout: false,
bc: false,
excludeddynamic: false,
furniline: '',
environment: '',
rare: false,
}); });
const setField = useCallback((key: string, value: unknown) => const setField = useCallback((key: string, value: unknown) =>
{ {
setForm(prev => ({ ...prev, [key]: value })); setForm(prev => ({ ...prev, [key]: value }));
setSuccess(null);
}, []); }, []);
const handleCreate = useCallback(async () => const handleCreate = useCallback(() =>
{ {
if(!form.itemName || !form.publicName) return; if(!form.itemName || !form.publicName) return;
const id = await onCreate(form); onCreate(form);
}, [ form, onCreate ]);
if(id)
{
setSuccess(id);
setTimeout(() => onCreated(id), 1000);
}
}, [ form, onCreate, onCreated ]);
const inputClass = 'form-control form-control-sm'; const inputClass = 'form-control form-control-sm';
const labelClass = 'text-[11px] font-bold text-[#333] mb-0'; const labelClass = 'text-[11px] font-bold text-[#333] mb-0';
return ( return (
<Column gap={ 1 } className="h-full overflow-auto"> <Column gap={ 1 } className="h-full overflow-auto">
{ success && <Flex gap={ 1 } alignItems="center" className="mb-1">
<div className="bg-[#d4edda] border border-[#c3e6cb] rounded p-2 text-[#155724] text-xs"> <Button variant="secondary" onClick={ onBack }>Back</Button>
Item created with ID #{ success }! <Text bold className="text-[14px]">Create New Item</Text>
</div> </Flex>
}
<div className="bg-white rounded border border-[#ccc] p-2"> <div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">Basic Info</Text> <Text small bold variant="primary" className="mb-1 block">Basic Info</Text>
@@ -77,6 +81,10 @@ export const FurniEditorCreateView: FC<FurniEditorCreateViewProps> = props =>
<label className={ labelClass }>Public Name *</label> <label className={ labelClass }>Public Name *</label>
<input className={ inputClass } value={ form.publicName } onChange={ e => setField('publicName', e.target.value) } placeholder="My Custom Furni" /> <input className={ inputClass } value={ form.publicName } onChange={ e => setField('publicName', e.target.value) } placeholder="My Custom Furni" />
</div> </div>
<div className="col-span-2">
<label className={ labelClass }>Description</label>
<textarea className={ inputClass } rows={ 2 } value={ form.description } onChange={ e => setField('description', e.target.value) } />
</div>
<div> <div>
<label className={ labelClass }>Sprite ID</label> <label className={ labelClass }>Sprite ID</label>
<input type="number" className={ inputClass } value={ form.spriteId } onChange={ e => setField('spriteId', Number(e.target.value)) } /> <input type="number" className={ inputClass } value={ form.spriteId } onChange={ e => setField('spriteId', Number(e.target.value)) } />
@@ -93,7 +101,7 @@ export const FurniEditorCreateView: FC<FurniEditorCreateViewProps> = props =>
<div className="bg-white rounded border border-[#ccc] p-2"> <div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">Dimensions</Text> <Text small bold variant="primary" className="mb-1 block">Dimensions</Text>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-4 gap-2">
<div> <div>
<label className={ labelClass }>Width</label> <label className={ labelClass }>Width</label>
<input type="number" className={ inputClass } value={ form.width } onChange={ e => setField('width', Number(e.target.value)) } /> <input type="number" className={ inputClass } value={ form.width } onChange={ e => setField('width', Number(e.target.value)) } />
@@ -106,6 +114,10 @@ export const FurniEditorCreateView: FC<FurniEditorCreateViewProps> = props =>
<label className={ labelClass }>Stack Height</label> <label className={ labelClass }>Stack Height</label>
<input type="number" step="0.01" className={ inputClass } value={ form.stackHeight } onChange={ e => setField('stackHeight', Number(e.target.value)) } /> <input type="number" step="0.01" className={ inputClass } value={ form.stackHeight } onChange={ e => setField('stackHeight', Number(e.target.value)) } />
</div> </div>
<div>
<label className={ labelClass }>Default Dir</label>
<input type="number" className={ inputClass } value={ form.defaultdir } onChange={ e => setField('defaultdir', Number(e.target.value)) } />
</div>
</div> </div>
</div> </div>
@@ -149,6 +161,55 @@ export const FurniEditorCreateView: FC<FurniEditorCreateViewProps> = props =>
</div> </div>
</div> </div>
<div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">FurniData.json</Text>
<div className="grid grid-cols-3 gap-2">
<div>
<label className={ labelClass }>Revision</label>
<input type="number" className={ inputClass } value={ form.revision } onChange={ e => setField('revision', Number(e.target.value)) } />
</div>
<div>
<label className={ labelClass }>Category</label>
<input className={ inputClass } value={ form.category } onChange={ e => setField('category', e.target.value) } />
</div>
<div>
<label className={ labelClass }>Offer ID</label>
<input type="number" className={ inputClass } value={ form.offerid } onChange={ e => setField('offerid', Number(e.target.value)) } />
</div>
<div>
<label className={ labelClass }>Rent Offer ID</label>
<input type="number" className={ inputClass } value={ form.rentofferid } onChange={ e => setField('rentofferid', Number(e.target.value)) } />
</div>
<div>
<label className={ labelClass }>Furniline</label>
<input className={ inputClass } value={ form.furniline } onChange={ e => setField('furniline', e.target.value) } />
</div>
<div>
<label className={ labelClass }>Environment</label>
<input className={ inputClass } value={ form.environment } onChange={ e => setField('environment', e.target.value) } />
</div>
</div>
<div className="grid grid-cols-4 gap-x-3 gap-y-1 mt-1">
{ [
['buyout', 'Buyout'],
['rentbuyout', 'Rent Buyout'],
['bc', 'BC'],
['excludeddynamic', 'Excl. Dynamic'],
['rare', 'Rare']
].map(([ key, label ]) => (
<label key={ key } className="flex items-center gap-1 text-[11px] cursor-pointer">
<input
type="checkbox"
className="form-check-input"
checked={ (form as any)[key] }
onChange={ e => setField(key, e.target.checked) }
/>
{ label }
</label>
)) }
</div>
</div>
<Flex className="mt-1"> <Flex className="mt-1">
<Button variant="success" disabled={ loading || !form.itemName || !form.publicName } onClick={ handleCreate }> <Button variant="success" disabled={ loading || !form.itemName || !form.publicName } onClick={ handleCreate }>
{ loading ? 'Creating...' : 'Create Item' } { loading ? 'Creating...' : 'Create Item' }
@@ -9,8 +9,8 @@ interface FurniEditorEditViewProps
furniDataEntry: Record<string, unknown> | null; furniDataEntry: Record<string, unknown> | null;
interactions: string[]; interactions: string[];
loading: boolean; loading: boolean;
onUpdate: (id: number, fields: Record<string, unknown>) => Promise<boolean>; onUpdate: (id: number, fields: Record<string, unknown>) => void;
onDelete: (id: number) => Promise<boolean>; onDelete: (id: number) => void;
onBack: () => void; onBack: () => void;
onRefresh: (id: number) => void; onRefresh: (id: number) => void;
} }
@@ -39,6 +39,19 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
interactionType: '', interactionType: '',
interactionModesCount: 0, interactionModesCount: 0,
customparams: '', customparams: '',
description: '',
revision: 0,
category: '',
defaultdir: 0,
offerid: 0,
buyout: false,
rentofferid: 0,
rentbuyout: false,
bc: false,
excludeddynamic: false,
furniline: '',
environment: '',
rare: false,
}); });
const [ confirmDelete, setConfirmDelete ] = useState(false); const [ confirmDelete, setConfirmDelete ] = useState(false);
@@ -67,6 +80,19 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
interactionType: item.interactionType || '', interactionType: item.interactionType || '',
interactionModesCount: item.interactionModesCount || 0, interactionModesCount: item.interactionModesCount || 0,
customparams: item.customparams || '', customparams: item.customparams || '',
description: item.description || '',
revision: item.revision || 0,
category: item.category || '',
defaultdir: item.defaultdir || 0,
offerid: item.offerid || 0,
buyout: !!item.buyout,
rentofferid: item.rentofferid || 0,
rentbuyout: !!item.rentbuyout,
bc: !!item.bc,
excludeddynamic: !!item.excludeddynamic,
furniline: item.furniline || '',
environment: item.environment || '',
rare: !!item.rare,
}); });
setConfirmDelete(false); setConfirmDelete(false);
@@ -77,20 +103,17 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
setForm(prev => ({ ...prev, [key]: value })); setForm(prev => ({ ...prev, [key]: value }));
}, []); }, []);
const handleSave = useCallback(async () => const handleSave = useCallback(() =>
{ {
const ok = await onUpdate(item.id, form); onUpdate(item.id, form);
}, [ item, form, onUpdate ]);
if(ok) onRefresh(item.id); const handleDelete = useCallback(() =>
}, [ item, form, onUpdate, onRefresh ]);
const handleDelete = useCallback(async () =>
{ {
if(!confirmDelete) return setConfirmDelete(true); if(!confirmDelete) return setConfirmDelete(true);
const ok = await onDelete(item.id); onDelete(item.id);
onBack();
if(ok) onBack();
}, [ confirmDelete, item, onDelete, onBack ]); }, [ confirmDelete, item, onDelete, onBack ]);
const inputClass = 'form-control form-control-sm'; const inputClass = 'form-control form-control-sm';
@@ -101,15 +124,9 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
<Flex gap={ 1 } alignItems="center" className="mb-1"> <Flex gap={ 1 } alignItems="center" className="mb-1">
<Button variant="secondary" onClick={ onBack }>Back</Button> <Button variant="secondary" onClick={ onBack }>Back</Button>
<Flex alignItems="center" gap={ 1 } className="bg-[#e9ecef] px-2 py-0.5 rounded"> <Flex alignItems="center" gap={ 1 } className="bg-[#e9ecef] px-2 py-0.5 rounded">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-3 h-3 text-[#1e7295]"> <Text bold className="text-[12px]">ID: { item.id }</Text>
<path fillRule="evenodd" d="M4.93 1.31a41.401 41.401 0 0 1 10.14 0C16.194 1.45 17 2.414 17 3.517V18.25a.75.75 0 0 1-1.075.676l-2.8-1.344-2.8 1.344a.75.75 0 0 1-.65 0l-2.8-1.344-2.8 1.344A.75.75 0 0 1 3 18.25V3.517c0-1.103.806-2.068 1.93-2.207Z" clipRule="evenodd" />
</svg>
<Text bold className="text-[12px]">{ item.id }</Text>
<span className="text-[#999] mx-0.5">|</span> <span className="text-[#999] mx-0.5">|</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-3 h-3 text-[#1e7295]"> <Text bold className="text-[12px]">Sprite: { item.spriteId }</Text>
<path d="M12.586 2.586a2 2 0 1 1 2.828 2.828l-3 3a2 2 0 0 1-2.828 0 1 1 0 0 0-1.414 1.414 4 4 0 0 0 5.656 0l3-3a4 4 0 0 0-5.656-5.656l-1.5 1.5a1 1 0 1 0 1.414 1.414l1.5-1.5ZM7.414 17.414a2 2 0 1 1-2.828-2.828l3-3a2 2 0 0 1 2.828 0 1 1 0 0 0 1.414-1.414 4 4 0 0 0-5.656 0l-3 3a4 4 0 0 0 5.656 5.656l1.5-1.5a1 1 0 1 0-1.414-1.414l-1.5 1.5Z" />
</svg>
<Text bold className="text-[12px]">{ item.spriteId }</Text>
</Flex> </Flex>
<Text small variant="gray">({ item.usageCount } in use)</Text> <Text small variant="gray">({ item.usageCount } in use)</Text>
</Flex> </Flex>
@@ -126,6 +143,10 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
<label className={ labelClass }>Public Name</label> <label className={ labelClass }>Public Name</label>
<input className={ inputClass } value={ form.publicName } onChange={ e => setField('publicName', e.target.value) } /> <input className={ inputClass } value={ form.publicName } onChange={ e => setField('publicName', e.target.value) } />
</div> </div>
<div className="col-span-2">
<label className={ labelClass }>Description</label>
<textarea className={ inputClass } rows={ 2 } value={ form.description } onChange={ e => setField('description', e.target.value) } />
</div>
<div> <div>
<label className={ labelClass }>Sprite ID</label> <label className={ labelClass }>Sprite ID</label>
<input type="number" className={ inputClass } value={ form.spriteId } onChange={ e => setField('spriteId', Number(e.target.value)) } /> <input type="number" className={ inputClass } value={ form.spriteId } onChange={ e => setField('spriteId', Number(e.target.value)) } />
@@ -143,7 +164,7 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
{ /* Dimensions */ } { /* Dimensions */ }
<div className="bg-white rounded border border-[#ccc] p-2"> <div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">Dimensions</Text> <Text small bold variant="primary" className="mb-1 block">Dimensions</Text>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-4 gap-2">
<div> <div>
<label className={ labelClass }>Width</label> <label className={ labelClass }>Width</label>
<input type="number" className={ inputClass } value={ form.width } onChange={ e => setField('width', Number(e.target.value)) } /> <input type="number" className={ inputClass } value={ form.width } onChange={ e => setField('width', Number(e.target.value)) } />
@@ -156,6 +177,10 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
<label className={ labelClass }>Stack Height</label> <label className={ labelClass }>Stack Height</label>
<input type="number" step="0.01" className={ inputClass } value={ form.stackHeight } onChange={ e => setField('stackHeight', Number(e.target.value)) } /> <input type="number" step="0.01" className={ inputClass } value={ form.stackHeight } onChange={ e => setField('stackHeight', Number(e.target.value)) } />
</div> </div>
<div>
<label className={ labelClass }>Default Dir</label>
<input type="number" className={ inputClass } value={ form.defaultdir } onChange={ e => setField('defaultdir', Number(e.target.value)) } />
</div>
</div> </div>
</div> </div>
@@ -201,6 +226,56 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
</div> </div>
</div> </div>
{ /* FurniData JSON */ }
<div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">FurniData.json</Text>
<div className="grid grid-cols-3 gap-2">
<div>
<label className={ labelClass }>Revision</label>
<input type="number" className={ inputClass } value={ form.revision } onChange={ e => setField('revision', Number(e.target.value)) } />
</div>
<div>
<label className={ labelClass }>Category</label>
<input className={ inputClass } value={ form.category } onChange={ e => setField('category', e.target.value) } />
</div>
<div>
<label className={ labelClass }>Offer ID</label>
<input type="number" className={ inputClass } value={ form.offerid } onChange={ e => setField('offerid', Number(e.target.value)) } />
</div>
<div>
<label className={ labelClass }>Rent Offer ID</label>
<input type="number" className={ inputClass } value={ form.rentofferid } onChange={ e => setField('rentofferid', Number(e.target.value)) } />
</div>
<div>
<label className={ labelClass }>Furniline</label>
<input className={ inputClass } value={ form.furniline } onChange={ e => setField('furniline', e.target.value) } />
</div>
<div>
<label className={ labelClass }>Environment</label>
<input className={ inputClass } value={ form.environment } onChange={ e => setField('environment', e.target.value) } />
</div>
</div>
<div className="grid grid-cols-4 gap-x-3 gap-y-1 mt-1">
{ [
['buyout', 'Buyout'],
['rentbuyout', 'Rent Buyout'],
['bc', 'BC'],
['excludeddynamic', 'Excl. Dynamic'],
['rare', 'Rare']
].map(([ key, label ]) => (
<label key={ key } className="flex items-center gap-1 text-[11px] cursor-pointer">
<input
type="checkbox"
className="form-check-input"
checked={ (form as any)[key] }
onChange={ e => setField(key, e.target.checked) }
/>
{ label }
</label>
)) }
</div>
</div>
{ /* Catalog References */ } { /* Catalog References */ }
{ catalogItems.length > 0 && { catalogItems.length > 0 &&
<div className="bg-white rounded border border-[#ccc] p-2"> <div className="bg-white rounded border border-[#ccc] p-2">
@@ -216,21 +291,6 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
</div> </div>
} }
{ /* FurniData.json Entry */ }
{ furniDataEntry &&
<div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">FurniData.json</Text>
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5 text-[10px]">
{ Object.entries(furniDataEntry).map(([ key, value ]) => (
<div key={ key } className="flex justify-between bg-[#f5f5f5] px-2 py-0.5 rounded">
<span className="font-bold text-[#555]">{ key }</span>
<span className="text-[#333] truncate ml-1 max-w-[120px] text-right">{ String(value ?? '') }</span>
</div>
)) }
</div>
</div>
}
{ /* Actions */ } { /* Actions */ }
<Flex gap={ 1 } justifyContent="between" className="mt-1"> <Flex gap={ 1 } justifyContent="between" className="mt-1">
<Button variant="success" disabled={ loading } onClick={ handleSave }> <Button variant="success" disabled={ loading } onClick={ handleSave }>
+44 -1
View File
@@ -4,7 +4,50 @@
@font-face { @font-face {
font-family: Ubuntu; font-family: Ubuntu;
src: url("@/assets/webfonts/Ubuntu-C.ttf"); src: url("@/assets/webfonts/Ubuntu.ttf") format("truetype");
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: Ubuntu;
src: url("@/assets/webfonts/Ubuntu-m.ttf") format("truetype");
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: Ubuntu;
src: url("@/assets/webfonts/Ubuntu-b.ttf") format("truetype");
font-weight: bold;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: Ubuntu;
src: url("@/assets/webfonts/Ubuntu-i.ttf") format("truetype");
font-weight: normal;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: Volter;
src: url("@/assets/webfonts/Volter.ttf") format("truetype");
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: Volter;
src: url("@/assets/webfonts/Volter-b.ttf") format("truetype");
font-weight: bold;
font-style: normal;
font-display: swap;
} }
html, html,
+1
View File
@@ -1,3 +1,4 @@
export * from './useCatalog'; export * from './useCatalog';
export * from './useCatalogFavorites';
export * from './useCatalogPlaceMultipleItems'; export * from './useCatalogPlaceMultipleItems';
export * from './useCatalogSkipPurchaseConfirmation'; export * from './useCatalogSkipPurchaseConfirmation';
+129
View File
@@ -0,0 +1,129 @@
import { useCallback, useEffect, useState } from 'react';
import { useBetween } from 'use-between';
export interface IFavoriteOffer
{
offerId: number;
name?: string;
iconUrl?: string;
}
const STORAGE_KEY_OFFERS = 'catalog_fav_offers_v2';
const STORAGE_KEY_PAGES = 'catalog_fav_pages';
const readOffers = (): IFavoriteOffer[] =>
{
try
{
const raw = localStorage.getItem(STORAGE_KEY_OFFERS);
if(!raw) return [];
const parsed = JSON.parse(raw);
if(!Array.isArray(parsed)) return [];
// migrate from old format (number[]) to new format (IFavoriteOffer[])
if(parsed.length > 0 && typeof parsed[0] === 'number')
{
return (parsed as number[]).map(id => ({ offerId: id }));
}
return parsed;
}
catch
{
return [];
}
};
const readPages = (): number[] =>
{
try
{
const raw = localStorage.getItem(STORAGE_KEY_PAGES);
if(!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
}
catch
{
return [];
}
};
const writeOffers = (offers: IFavoriteOffer[]) =>
{
localStorage.setItem(STORAGE_KEY_OFFERS, JSON.stringify(offers));
};
const writePages = (ids: number[]) =>
{
localStorage.setItem(STORAGE_KEY_PAGES, JSON.stringify(ids));
};
const useCatalogFavoritesState = () =>
{
const [ favoriteOffers, setFavoriteOffers ] = useState<IFavoriteOffer[]>([]);
const [ favoritePageIds, setFavoritePageIds ] = useState<number[]>([]);
const [ loaded, setLoaded ] = useState(false);
const favoriteOfferIds = favoriteOffers.map(f => f.offerId);
const loadFavorites = useCallback(() =>
{
setFavoriteOffers(readOffers());
setFavoritePageIds(readPages());
setLoaded(true);
}, []);
useEffect(() =>
{
if(!loaded) loadFavorites();
}, [ loaded, loadFavorites ]);
const toggleFavoriteOffer = useCallback((offerId: number, name?: string, iconUrl?: string) =>
{
setFavoriteOffers(prev =>
{
const exists = prev.find(f => f.offerId === offerId);
if(exists)
{
const next = prev.filter(f => f.offerId !== offerId);
writeOffers(next);
return next;
}
const next = [ ...prev, { offerId, name, iconUrl } ];
writeOffers(next);
return next;
});
}, []);
const toggleFavoritePage = useCallback((pageId: number) =>
{
setFavoritePageIds(prev =>
{
const next = prev.includes(pageId) ? prev.filter(id => id !== pageId) : [ ...prev, pageId ];
writePages(next);
return next;
});
}, []);
const isFavoriteOffer = useCallback((offerId: number) =>
{
return favoriteOffers.some(f => f.offerId === offerId);
}, [ favoriteOffers ]);
const isFavoritePage = useCallback((pageId: number) =>
{
return favoritePageIds.includes(pageId);
}, [ favoritePageIds ]);
const getFavoriteOffer = useCallback((offerId: number): IFavoriteOffer | undefined =>
{
return favoriteOffers.find(f => f.offerId === offerId);
}, [ favoriteOffers ]);
return { favoriteOffers, favoriteOfferIds, favoritePageIds, loaded, loadFavorites, toggleFavoriteOffer, toggleFavoritePage, isFavoriteOffer, isFavoritePage, getFavoriteOffer };
};
export const useCatalogFavorites = () => useBetween(useCatalogFavoritesState);
+303 -142
View File
@@ -1,4 +1,7 @@
import { FurniEditorBySpriteComposer, FurniEditorCreateComposer, FurniEditorCreateResultEvent, FurniEditorDeleteComposer, FurniEditorDeleteResultEvent, FurniEditorDetailComposer, FurniEditorDetailResultEvent, FurniEditorInteractionsComposer, FurniEditorInteractionsResultEvent, FurniEditorSearchComposer, FurniEditorSearchResultEvent, FurniEditorUpdateComposer, FurniEditorUpdateResultEvent } from '@nitrots/nitro-renderer';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { SendMessageComposer } from '../../api';
import { useMessageEvent } from '../events';
export interface FurniItem export interface FurniItem
{ {
@@ -33,6 +36,18 @@ export interface FurniDetail extends FurniItem
multiheight: string; multiheight: string;
description: string; description: string;
usageCount: number; usageCount: number;
revision: number;
category: string;
defaultdir: number;
offerid: number;
buyout: boolean;
rentofferid: number;
rentbuyout: boolean;
bc: boolean;
excludeddynamic: boolean;
furniline: string;
environment: string;
rare: boolean;
} }
export interface CatalogRef export interface CatalogRef
@@ -46,16 +61,57 @@ export interface CatalogRef
pageName: string; pageName: string;
} }
const API_BASE = '/api/admin/furni-editor'; export const MAX_STRING_LENGTH = 255;
export const MAX_CUSTOM_PARAMS_LENGTH = 1000;
export const MAX_DIMENSION = 100;
export const MAX_STACK_HEIGHT = 100;
export const MAX_MODES_COUNT = 100;
async function apiFetch<T>(url: string, options?: RequestInit): Promise<T> export interface FurniFormErrors
{ {
const res = await fetch(url, { credentials: 'include', ...options }); itemName?: string;
const data = await res.json(); publicName?: string;
spriteId?: string;
width?: string;
length?: string;
stackHeight?: string;
interactionModesCount?: string;
customparams?: string;
}
if(!res.ok || data.error) throw new Error(data.error || 'API error'); export function validateFurniForm(fields: Record<string, unknown>): FurniFormErrors
{
const errors: FurniFormErrors = {};
return data; const itemName = String(fields.itemName ?? '').trim();
const publicName = String(fields.publicName ?? '').trim();
if(!itemName) errors.itemName = 'Item name is required';
else if(itemName.length > MAX_STRING_LENGTH) errors.itemName = `Max ${ MAX_STRING_LENGTH } characters`;
else if(!/^[a-zA-Z0-9_\- ]+$/.test(itemName)) errors.itemName = 'Only letters, numbers, _, - and spaces';
if(!publicName) errors.publicName = 'Public name is required';
else if(publicName.length > MAX_STRING_LENGTH) errors.publicName = `Max ${ MAX_STRING_LENGTH } characters`;
const spriteId = Number(fields.spriteId);
if(!Number.isFinite(spriteId) || spriteId < 0) errors.spriteId = 'Must be a positive number';
const width = Number(fields.width);
const length = Number(fields.length);
const stackHeight = Number(fields.stackHeight);
const modes = Number(fields.interactionModesCount);
if(!Number.isFinite(width) || width < 1 || width > MAX_DIMENSION) errors.width = `1-${ MAX_DIMENSION }`;
if(!Number.isFinite(length) || length < 1 || length > MAX_DIMENSION) errors.length = `1-${ MAX_DIMENSION }`;
if(!Number.isFinite(stackHeight) || stackHeight < 0 || stackHeight > MAX_STACK_HEIGHT) errors.stackHeight = `0-${ MAX_STACK_HEIGHT }`;
if(!Number.isFinite(modes) || modes < 0 || modes > MAX_MODES_COUNT) errors.interactionModesCount = `0-${ MAX_MODES_COUNT }`;
const customparams = String(fields.customparams ?? '');
if(customparams.length > MAX_CUSTOM_PARAMS_LENGTH) errors.customparams = `Max ${ MAX_CUSTOM_PARAMS_LENGTH } characters`;
return errors;
} }
export const useFurniEditor = () => export const useFurniEditor = () =>
@@ -72,164 +128,269 @@ export const useFurniEditor = () =>
const clearError = useCallback(() => setError(null), []); const clearError = useCallback(() => setError(null), []);
const searchItems = useCallback(async (query: string, type: string, pg: number) => // --- Message event handlers (incoming from server) ---
useMessageEvent<FurniEditorSearchResultEvent>(FurniEditorSearchResultEvent, useCallback(event =>
{
const parser = event.getParser();
setItems(parser.items.map(i => ({
id: i.id,
spriteId: i.spriteId,
itemName: i.itemName,
publicName: i.publicName,
type: i.type,
width: i.width,
length: i.length,
stackHeight: i.stackHeight,
allowStack: i.allowStack,
allowWalk: i.allowWalk,
allowSit: i.allowSit,
allowLay: i.allowLay,
interactionType: i.interactionType,
interactionModesCount: i.interactionModesCount
})));
setTotal(parser.total);
setPage(parser.page);
setLoading(false);
}, []));
useMessageEvent<FurniEditorDetailResultEvent>(FurniEditorDetailResultEvent, useCallback(event =>
{
const parser = event.getParser();
const i = parser.item;
setSelectedItem({
id: i.id,
spriteId: i.spriteId,
itemName: i.itemName,
publicName: i.publicName,
type: i.type,
width: i.width,
length: i.length,
stackHeight: i.stackHeight,
allowStack: i.allowStack,
allowWalk: i.allowWalk,
allowSit: i.allowSit,
allowLay: i.allowLay,
allowGift: i.allowGift,
allowTrade: i.allowTrade,
allowRecycle: i.allowRecycle,
allowMarketplaceSell: i.allowMarketplaceSell,
allowInventoryStack: i.allowInventoryStack,
interactionType: i.interactionType,
interactionModesCount: i.interactionModesCount,
customparams: i.customparams,
effectIdMale: i.effectIdMale,
effectIdFemale: i.effectIdFemale,
clothingOnWalk: i.clothingOnWalk,
vendingIds: i.vendingIds,
multiheight: i.multiheight,
description: i.description,
usageCount: i.usageCount,
revision: parser.revision,
category: parser.category,
defaultdir: parser.defaultdir,
offerid: parser.offerid,
buyout: parser.buyout,
rentofferid: parser.rentofferid,
rentbuyout: parser.rentbuyout,
bc: parser.bc,
excludeddynamic: parser.excludeddynamic,
furniline: parser.furniline,
environment: parser.environment,
rare: parser.rare
});
setCatalogItems(parser.catalogItems.map(ci => ({
id: ci.id,
catalogName: ci.catalogName,
costCredits: ci.costCredits,
costPoints: ci.costPoints,
pointsType: ci.pointsType,
pageId: ci.pageId,
pageName: ci.pageName
})));
let furniData: Record<string, unknown> | null = null;
if(parser.furniDataEntry)
{
try { furniData = JSON.parse(parser.furniDataEntry); }
catch { furniData = null; }
}
setFurniDataEntry(furniData);
setLoading(false);
}, []));
useMessageEvent<FurniEditorInteractionsResultEvent>(FurniEditorInteractionsResultEvent, useCallback(event =>
{
setInteractions(event.getParser().interactions);
}, []));
useMessageEvent<FurniEditorUpdateResultEvent>(FurniEditorUpdateResultEvent, useCallback(event =>
{
const parser = event.getParser();
setLoading(false);
if(!parser.success)
{
setError(parser.message);
}
else if(parser.id > 0)
{
SendMessageComposer(new FurniEditorDetailComposer(parser.id));
}
}, []));
useMessageEvent<FurniEditorCreateResultEvent>(FurniEditorCreateResultEvent, useCallback(event =>
{
const parser = event.getParser();
setLoading(false);
if(!parser.success)
{
setError(parser.message);
}
}, []));
useMessageEvent<FurniEditorDeleteResultEvent>(FurniEditorDeleteResultEvent, useCallback(event =>
{
const parser = event.getParser();
setLoading(false);
if(!parser.success)
{
setError(parser.message);
}
}, []));
// --- Outgoing commands (client to server) ---
const searchItems = useCallback((query: string, type: string, pg: number) =>
{
setLoading(true);
setError(null);
SendMessageComposer(new FurniEditorSearchComposer(query, type, pg));
}, []);
const loadDetail = useCallback((id: number) =>
{
setLoading(true);
setError(null);
SendMessageComposer(new FurniEditorDetailComposer(id));
}, []);
const loadBySpriteId = useCallback((spriteId: number) =>
{
setLoading(true);
setError(null);
SendMessageComposer(new FurniEditorBySpriteComposer(spriteId));
}, []);
const updateItem = useCallback((id: number, fields: Record<string, unknown>) =>
{ {
setLoading(true); setLoading(true);
setError(null); setError(null);
try const f = fields;
{
const params = new URLSearchParams({ q: query, limit: '20', page: String(pg) });
if(type) params.set('type', type); SendMessageComposer(new FurniEditorUpdateComposer(
id,
const data = await apiFetch<{ items: FurniItem[]; total: number; page: number }>(`${ API_BASE }?${ params }`); String(f.itemName ?? ''),
String(f.publicName ?? ''),
setItems(data.items); Number(f.spriteId ?? 0),
setTotal(data.total); String(f.type ?? 's'),
setPage(data.page); Number(f.width ?? 1),
} Number(f.length ?? 1),
catch(e: any) Number(f.stackHeight ?? 0),
{ !!f.allowStack,
setError(e.message); !!f.allowWalk,
} !!f.allowSit,
finally !!f.allowLay,
{ !!f.allowGift,
setLoading(false); !!f.allowTrade,
} !!f.allowRecycle,
!!f.allowMarketplaceSell,
!!f.allowInventoryStack,
String(f.interactionType ?? ''),
Number(f.interactionModesCount ?? 0),
String(f.customparams ?? ''),
String(f.description ?? ''),
Number(f.revision ?? 0),
String(f.category ?? ''),
Number(f.defaultdir ?? 0),
Number(f.offerid ?? 0),
!!f.buyout,
Number(f.rentofferid ?? 0),
!!f.rentbuyout,
!!f.bc,
!!f.excludeddynamic,
String(f.furniline ?? ''),
String(f.environment ?? ''),
!!f.rare
));
}, []); }, []);
const loadDetail = useCallback(async (id: number): Promise<boolean> => const createItem = useCallback((fields: Record<string, unknown>) =>
{ {
setLoading(true); setLoading(true);
setError(null); setError(null);
try const f = fields;
{
const data = await apiFetch<{ item: FurniDetail; catalogItems: CatalogRef[]; furniDataEntry: Record<string, unknown> | null }>(`${ API_BASE }/detail?id=${ id }`);
setSelectedItem(data.item); SendMessageComposer(new FurniEditorCreateComposer(
setCatalogItems(data.catalogItems); String(f.itemName ?? ''),
setFurniDataEntry(data.furniDataEntry); String(f.publicName ?? ''),
Number(f.spriteId ?? 0),
return true; String(f.type ?? 's'),
} Number(f.width ?? 1),
catch(e: any) Number(f.length ?? 1),
{ Number(f.stackHeight ?? 0),
setError(e.message); !!f.allowStack,
!!f.allowWalk,
return false; !!f.allowSit,
} !!f.allowLay,
finally !!f.allowGift,
{ !!f.allowTrade,
setLoading(false); !!f.allowRecycle,
} !!f.allowMarketplaceSell,
!!f.allowInventoryStack,
String(f.interactionType ?? ''),
Number(f.interactionModesCount ?? 0),
String(f.customparams ?? ''),
String(f.description ?? ''),
Number(f.revision ?? 0),
String(f.category ?? ''),
Number(f.defaultdir ?? 0),
Number(f.offerid ?? 0),
!!f.buyout,
Number(f.rentofferid ?? 0),
!!f.rentbuyout,
!!f.bc,
!!f.excludeddynamic,
String(f.furniline ?? ''),
String(f.environment ?? ''),
!!f.rare
));
}, []); }, []);
const updateItem = useCallback(async (id: number, fields: Record<string, unknown>) => const deleteItem = useCallback((id: number) =>
{ {
setLoading(true); setLoading(true);
setError(null); setError(null);
SendMessageComposer(new FurniEditorDeleteComposer(id));
try
{
await apiFetch(`${ API_BASE }/update?id=${ id }`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fields)
});
return true;
}
catch(e: any)
{
setError(e.message);
return false;
}
finally
{
setLoading(false);
}
}, []); }, []);
const createItem = useCallback(async (fields: Record<string, unknown>) => const loadInteractions = useCallback(() =>
{ {
setLoading(true); SendMessageComposer(new FurniEditorInteractionsComposer());
setError(null);
try
{
const data = await apiFetch<{ id: number }>(`${ API_BASE }`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fields)
});
return data.id;
}
catch(e: any)
{
setError(e.message);
return null;
}
finally
{
setLoading(false);
}
}, []); }, []);
const deleteItem = useCallback(async (id: number) =>
{
setLoading(true);
setError(null);
try
{
await apiFetch(`${ API_BASE }/delete?id=${ id }`, { method: 'POST' });
return true;
}
catch(e: any)
{
setError(e.message);
return false;
}
finally
{
setLoading(false);
}
}, []);
const loadInteractions = useCallback(async () =>
{
try
{
const data = await apiFetch<{ interactions: Array<string | { name: string }> }>(`${ API_BASE }/interactions`);
setInteractions(data.interactions.map(i => typeof i === 'string' ? i : i.name));
}
catch {}
}, []);
const loadBySpriteId = useCallback(async (spriteId: number): Promise<boolean> =>
{
try
{
const data = await apiFetch<{ id: number }>(`${ API_BASE }/by-sprite?spriteId=${ spriteId }`);
return await loadDetail(data.id);
}
catch(e: any)
{
setError(e.message);
return false;
}
}, [ loadDetail ]);
return { return {
items, total, page, loading, error, clearError, items, total, page, loading, error, clearError,
selectedItem, setSelectedItem, catalogItems, furniDataEntry, selectedItem, setSelectedItem, catalogItems, furniDataEntry,
+17 -1
View File
@@ -4,7 +4,21 @@ const {
generateShades generateShades
} = require('./css-utils/CSSColorUtils'); } = require('./css-utils/CSSColorUtils');
const catalogColors = {
'catalog-bg': '#eef1f5',
'catalog-surface': '#ffffff',
'catalog-rail-bg': '#e4e8ee',
'catalog-rail-hover': '#d8dde5',
'catalog-rail-active': '#cdd3db',
'catalog-border': '#c8ced6',
'catalog-text': '#2c3e50',
'catalog-text-muted': '#7f8c9b',
'catalog-accent': '#3b82f6',
'catalog-accent-hover': '#2563eb',
};
const colors = { const colors = {
...catalogColors,
'toolbar': '#555555', 'toolbar': '#555555',
'card-header': '#1E7295', 'card-header': '#1E7295',
'card-close': '#921911', 'card-close': '#921911',
@@ -57,7 +71,9 @@ const colors = {
const boxShadow = { const boxShadow = {
'inner1px': 'inset 0 0 0 1px rgba(255,255,255,.3)', 'inner1px': 'inset 0 0 0 1px rgba(255,255,255,.3)',
'room-previewer': '-2px -2px rgba(0, 0, 0, 0.4), inset 3px 3px rgba(0, 0, 0, 0.2);' 'room-previewer': '-2px -2px rgba(0, 0, 0, 0.4), inset 3px 3px rgba(0, 0, 0, 0.2);',
'catalog-card': '0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.06)',
'catalog-card-hover': '0 4px 12px rgba(0,0,0,0.12), 0 2px 4px rgba(0,0,0,0.08)',
}; };
module.exports = { module.exports = {
+2
View File
@@ -25,6 +25,8 @@ export default defineConfig({
alias: { alias: {
'@': resolve(__dirname, 'src'), '@': resolve(__dirname, 'src'),
'~': resolve(__dirname, 'node_modules'), '~': resolve(__dirname, 'node_modules'),
// Renderer3 root → resolve through its src/index.ts
'@nitrots/nitro-renderer': resolve(renderer3, 'src/index.ts'),
// Renderer3 workspace packages → point to their src/index.ts // Renderer3 workspace packages → point to their src/index.ts
'@nitrots/api': resolve(renderer3, 'packages/api/src/index.ts'), '@nitrots/api': resolve(renderer3, 'packages/api/src/index.ts'),
'@nitrots/assets': resolve(renderer3, 'packages/assets/src/index.ts'), '@nitrots/assets': resolve(renderer3, 'packages/assets/src/index.ts'),