diff --git a/CatalogTexts.json b/CatalogTexts.json new file mode 100644 index 0000000..7dbe65f --- /dev/null +++ b/CatalogTexts.json @@ -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" +} diff --git a/catalog_favorites.sql b/catalog_favorites.sql new file mode 100644 index 0000000..c178581 --- /dev/null +++ b/catalog_favorites.sql @@ -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) +); diff --git a/src/components/catalog/CatalogAdminContext.tsx b/src/components/catalog/CatalogAdminContext.tsx new file mode 100644 index 0000000..dc64894 --- /dev/null +++ b/src/components/catalog/CatalogAdminContext.tsx @@ -0,0 +1,249 @@ +import { createContext, FC, ReactNode, useCallback, useContext, useState } from 'react'; +import { ICatalogNode, IPurchasableOffer } from '../../api'; + +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) => Promise; + createPage: (data: IPageEditData) => Promise; + deletePage: (pageId: number) => Promise; + saveOffer: (data: IOfferEditData) => Promise; + createOffer: (data: IOfferEditData) => Promise; + deleteOffer: (offerId: number) => Promise; + reorderOffers: (orders: { id: number; orderNumber: number }[]) => Promise; + togglePageEnabled: (pageId: number) => Promise; + togglePageVisible: (pageId: number) => Promise; +} + +const CatalogAdminContext = createContext(null); + +export const useCatalogAdmin = () => useContext(CatalogAdminContext); + +const API_BASE = '/api/admin/catalog'; + +async function apiCall(url: string, method: string, body?: unknown): Promise<{ ok: boolean; data?: Record; error?: string }> +{ + try + { + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + ...(body !== undefined ? { body: JSON.stringify(body) } : {}) + }); + + const json = await res.json(); + + if(!res.ok || json.error) + { + return { ok: false, error: json.error || `HTTP ${ res.status }` }; + } + + return { ok: true, data: json }; + } + catch(err) + { + return { ok: false, error: (err as Error).message }; + } +} + +export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children }) => +{ + const [ adminMode, setAdminMode ] = useState(false); + const [ editingOffer, setEditingOffer ] = useState(null); + const [ editingPageData, setEditingPageData ] = useState(false); + const [ editingRootPage, setEditingRootPage ] = useState(false); + const [ editingPageNode, setEditingPageNode ] = useState(null); + const [ loading, setLoading ] = useState(false); + const [ lastError, setLastError ] = useState(null); + + const withLoading = useCallback(async (fn: () => Promise): Promise => + { + setLoading(true); + setLastError(null); + + try + { + return await fn(); + } + finally + { + setLoading(false); + } + }, []); + + const savePage = useCallback((data: IPageEditData): Promise => + { + return withLoading(async () => + { + const { pageId, ...fields } = data; + const result = await apiCall(`${ API_BASE }?id=${ pageId }`, 'PUT', fields); + + if(!result.ok) { setLastError(result.error); return false; } + + return true; + }); + }, [ withLoading ]); + + const createPage = useCallback((data: IPageEditData): Promise => + { + return withLoading(async () => + { + const result = await apiCall(API_BASE, 'POST', data); + + if(!result.ok) { setLastError(result.error); return false; } + + return true; + }); + }, [ withLoading ]); + + const deletePage = useCallback((pageId: number): Promise => + { + return withLoading(async () => + { + const result = await apiCall(API_BASE, 'DELETE', { id: pageId }); + + if(!result.ok) { setLastError(result.error); return false; } + + return true; + }); + }, [ withLoading ]); + + const saveOffer = useCallback((data: IOfferEditData): Promise => + { + return withLoading(async () => + { + const { offerId, ...fields } = data; + const result = await apiCall(`${ API_BASE }/items?id=${ offerId }`, 'PUT', fields); + + if(!result.ok) { setLastError(result.error); return false; } + + return true; + }); + }, [ withLoading ]); + + const createOffer = useCallback((data: IOfferEditData): Promise => + { + return withLoading(async () => + { + const result = await apiCall(`${ API_BASE }/items`, 'POST', data); + + if(!result.ok) { setLastError(result.error); return false; } + + return true; + }); + }, [ withLoading ]); + + const deleteOffer = useCallback((offerId: number): Promise => + { + return withLoading(async () => + { + const result = await apiCall(`${ API_BASE }/items`, 'DELETE', { id: offerId }); + + if(!result.ok) { setLastError(result.error); return false; } + + return true; + }); + }, [ withLoading ]); + + const reorderOffers = useCallback((orders: { id: number; orderNumber: number }[]): Promise => + { + return withLoading(async () => + { + const result = await apiCall(`${ API_BASE }/items`, 'PATCH', { action: 'reorder', orders }); + + if(!result.ok) { setLastError(result.error); return false; } + + return true; + }); + }, [ withLoading ]); + + const togglePageEnabled = useCallback((pageId: number): Promise => + { + return withLoading(async () => + { + const result = await apiCall(API_BASE, 'PATCH', { action: 'toggleEnabled', id: pageId }); + + if(!result.ok) { setLastError(result.error); return false; } + + return true; + }); + }, [ withLoading ]); + + const togglePageVisible = useCallback((pageId: number): Promise => + { + return withLoading(async () => + { + const result = await apiCall(API_BASE, 'PATCH', { action: 'toggleVisible', id: pageId }); + + if(!result.ok) { setLastError(result.error); return false; } + + return true; + }); + }, [ withLoading ]); + + return ( + + { children } + + ); +}; diff --git a/src/components/catalog/CatalogView.tsx b/src/components/catalog/CatalogView.tsx index fc5f633..9e6ede9 100644 --- a/src/components/catalog/CatalogView.tsx +++ b/src/components/catalog/CatalogView.tsx @@ -1,17 +1,31 @@ -import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; -import { FC, useEffect } from 'react'; -import { GetConfigurationValue, LocalizeText } from '../../api'; -import { Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common'; -import { useCatalog } from '../../hooks'; +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'; -export const CatalogView: FC<{}> = props => +const CatalogViewInner: 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 { 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 { favoriteOfferIds, favoritePageIds } = useCatalogFavorites(); + const [ showFavorites, setShowFavorites ] = useState(false); + + const isMod = GetSessionDataManager().isModerator; + const totalFavs = favoriteOfferIds.length + favoritePageIds.length; useEffect(() => { @@ -69,43 +83,199 @@ export const CatalogView: FC<{}> = props => return ( <> { isVisible && - - setIsVisible(false) } /> - - { rootNode && (rootNode.children.length > 0) && rootNode.children.map((child, index) => - { - if(!child.isVisible) return null; + + setIsVisible(false) } /> + + { /* Admin banner */ } + { adminMode && +
+ ⚙ Admin Mode Attivo +
} - return ( - - { - if(searchResult) setSearchResult(null); +
+ { /* === LEFT SIDEBAR === */ } +
- activateNode(child); - } } > -
- { GetConfigurationValue('catalog.tab.icons') && } - { child.localization } + { /* Favorites toggle */ } +
setShowFavorites(!showFavorites) } + > +
+ 0 ? 'text-danger' : 'text-muted' }` } /> + { totalFavs > 0 && + + { totalFavs } + }
- - ); - }) } - - - - { !navigationHidden && - - { activeNodes && (activeNodes.length > 0) && - } - } - - { GetCatalogLayout(currentPage, () => setNavigationHidden(true)) } - - + { LocalizeText('catalog.favorites') } +
+ +
+ + { /* Admin: root page actions */ } + { adminMode && rootNode && +
+ + +
} + + { /* Category icons */ } + { rootNode && rootNode.children.length > 0 && rootNode.children.map((child, index) => + { + if(!adminMode && !child.isVisible) return null; + + const isHidden = !child.isVisible; + + return ( +
+ { + if(searchResult) setSearchResult(null); + if(showFavorites) setShowFavorites(false); + activateNode(child); + } } + > +
+ + { isHidden && } +
+ + { child.localization } + + { /* Admin actions on each root category */ } + { adminMode && +
+
+ { + e.stopPropagation(); + catalogAdmin.setEditingPageNode(child); + catalogAdmin.setEditingRootPage(false); + catalogAdmin.setEditingPageData(true); + } } + > + +
+
+ { + e.stopPropagation(); + catalogAdmin.togglePageVisible(child.pageId); + } } + > + { isHidden + ? + : } +
+
+ { + e.stopPropagation(); + if(confirm(LocalizeText('catalog.admin.delete.category.confirm', [ 'name' ], [ child.localization ]))) + { + catalogAdmin.deletePage(child.pageId); + } + } } + > + +
+
} +
+ ); + }) } +
+ + { /* === MAIN AREA === */ } +
+ { /* Toolbar: search + admin */ } +
+ { /* Breadcrumb */ } +
+ + { activeNodes && activeNodes.length > 0 + ? activeNodes.map((node, i) => ( + + { i > 0 && } + activateNode(node) : undefined }> + { node.localization } + + + )) + : { LocalizeText('catalog.title') } } +
+ +
+ +
+ + { isMod && + } +
+ + { /* Content area */ } +
+ { showFavorites + ?
+ setShowFavorites(false) } /> +
+ : <> + { !navigationHidden && activeNodes && activeNodes.length > 0 && +
+ +
} +
+ { adminMode && } + { GetCatalogLayout(currentPage, () => setNavigationHidden(true)) } +
+ } +
+
+
} + ); }; + +export const CatalogView: FC<{}> = () => +{ + return ( + + + + ); +}; diff --git a/src/components/catalog/views/admin/CatalogAdminOfferEditView.tsx b/src/components/catalog/views/admin/CatalogAdminOfferEditView.tsx new file mode 100644 index 0000000..f6da55a --- /dev/null +++ b/src/components/catalog/views/admin/CatalogAdminOfferEditView.tsx @@ -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( +
setEditingOffer(null) }> +
+ +
e.stopPropagation() }> + { /* Header */ } +
+ + { isNew ? LocalizeText('catalog.admin.offer.new') : `${ LocalizeText('catalog.admin.offer.edit') } #${ editingOffer.offerId }` } + +
setEditingOffer(null) }> + +
+
+ +
+ { /* Current name */ } + { !isNew && +
+ { editingOffer.localizationName } +
} + + { /* Catalog Name */ } +
+ + setCatalogName(e.target.value) } /> +
+ + { /* Generale */ } +
+
{ LocalizeText('catalog.admin.offer.general') }
+
+
+ + setItemIds(e.target.value) } /> +
+
+ + setAmount(parseInt(e.target.value) || 1) } /> +
+
+ + setOrderNumber(parseInt(e.target.value) || 0) } /> +
+
+
+ + { /* Prezzi */ } +
+
{ LocalizeText('catalog.admin.offer.prices') }
+
+
+ + setCostCredits(parseInt(e.target.value) || 0) } /> +
+
+ + setCostPoints(parseInt(e.target.value) || 0) } /> +
+
+ + +
+
+
+ + { /* Opzioni */ } +
+
{ LocalizeText('catalog.admin.offer.options') }
+
+
+ + +
+
+ + setLimitedStack(parseInt(e.target.value) || 0) } /> +
+
+ + setOfferIdGroup(parseInt(e.target.value) || -1) } /> +
+
+
+ + setExtradata(e.target.value) } /> +
+
+ setHaveOffer(e.target.checked ? '1' : '0') } /> + +
+
+ + { /* Actions */ } +
+ { !isNew + ? + :
} + +
+
+
+
, + document.body + ); +}; diff --git a/src/components/catalog/views/admin/CatalogAdminPageEditView.tsx b/src/components/catalog/views/admin/CatalogAdminPageEditView.tsx new file mode 100644 index 0000000..6fd4a07 --- /dev/null +++ b/src/components/catalog/views/admin/CatalogAdminPageEditView.tsx @@ -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 ( +
+
+ + { isRoot ? LocalizeText('catalog.admin.edit.root') : `${ LocalizeText('catalog.admin.edit') } ${ targetNode.localization } (#${ targetPageId })` } + + +
+ +
+
+ + setCaption(e.target.value) } /> +
+
+ + setMinRank(parseInt(e.target.value) || 1) } /> +
+
+ + +
+
+ + setOrderNum(parseInt(e.target.value) || 0) } /> +
+
+ + +
+
+ +
+ { !isRoot + ? + :
} + +
+
+ ); +}; diff --git a/src/components/catalog/views/catalog-rail/CatalogRailItemView.tsx b/src/components/catalog/views/catalog-rail/CatalogRailItemView.tsx new file mode 100644 index 0000000..3b1cd21 --- /dev/null +++ b/src/components/catalog/views/catalog-rail/CatalogRailItemView.tsx @@ -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 = props => +{ + const { node, isActive, onClick } = props; + + return ( +
+
+ +
+ + { node.localization } + +
+ ); +}; diff --git a/src/components/catalog/views/favorites/CatalogFavoritesView.tsx b/src/components/catalog/views/favorites/CatalogFavoritesView.tsx new file mode 100644 index 0000000..676f771 --- /dev/null +++ b/src/components/catalog/views/favorites/CatalogFavoritesView.tsx @@ -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 = 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 ( +
+ { /* Header */ } +
+
+ + { LocalizeText('catalog.favorites') } + ({ enrichedOffers.length + favoritePages.length }) +
+ +
+ +
+ { /* Favorite Pages */ } + { favoritePages.length > 0 && +
+
+ + { LocalizeText('catalog.favorites.pages') } +
+
+ { favoritePages.map(page => ( +
{ activateNode(page.node); onClose(); } } + > + + { page.name } + { e.stopPropagation(); toggleFavoritePage(page.pageId); } } + /> +
+ )) } +
+
} + + { /* Favorite Offers */ } + { enrichedOffers.length > 0 && +
+
+ + { LocalizeText('catalog.favorites.furni') } +
+
+ { enrichedOffers.map(fav => ( +
{ openPageByOfferId(fav.offerId); onClose(); } } + > + { /* Furni icon */ } +
+ { fav.iconUrl + ? + : fav.nodeIconId !== null + ? + : + } +
+ { fav.displayName } + { e.stopPropagation(); toggleFavoriteOffer(fav.offerId); } } + /> +
+ )) } +
+
} + + { /* Empty state */ } + { favoritePages.length === 0 && enrichedOffers.length === 0 && +
+
+ +

{ LocalizeText('catalog.favorites.empty') }

+

{ LocalizeText('catalog.favorites.empty.hint') }

+
+
} +
+
+ ); +}; diff --git a/src/components/catalog/views/navigation/CatalogBreadcrumbView.tsx b/src/components/catalog/views/navigation/CatalogBreadcrumbView.tsx new file mode 100644 index 0000000..60ed73a --- /dev/null +++ b/src/components/catalog/views/navigation/CatalogBreadcrumbView.tsx @@ -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 ( +
+ + { LocalizeText('catalog.title') } +
+ ); + } + + return ( +
+ activateNode(activeNodes[0]) } + /> + { activeNodes.map((node, i) => ( + + + activateNode(node) : undefined } + > + { node.localization } + + + )) } +
+ ); +}; diff --git a/src/components/catalog/views/navigation/CatalogNavigationItemView.tsx b/src/components/catalog/views/navigation/CatalogNavigationItemView.tsx index 48ea04e..a0c980f 100644 --- a/src/components/catalog/views/navigation/CatalogNavigationItemView.tsx +++ b/src/components/catalog/views/navigation/CatalogNavigationItemView.tsx @@ -1,8 +1,8 @@ import { FC } from 'react'; -import { FaCaretDown, FaCaretUp } from 'react-icons/fa'; -import { ICatalogNode } from '../../../../api'; -import { LayoutGridItem, Text } from '../../../../common'; -import { useCatalog } from '../../../../hooks'; +import { FaCaretDown, FaCaretUp, FaPlus, FaStar, FaTrash } from 'react-icons/fa'; +import { ICatalogNode, LocalizeText } from '../../../../api'; +import { useCatalog, useCatalogFavorites } from '../../../../hooks'; +import { useCatalogAdmin } from '../../CatalogAdminContext'; import { CatalogIconView } from '../catalog-icon/CatalogIconView'; import { CatalogNavigationSetView } from './CatalogNavigationSetView'; @@ -16,18 +16,63 @@ export const CatalogNavigationItemView: FC = pro { const { node = null, child = false } = props; const { activateNode = null } = useCatalog(); + const catalogAdmin = useCatalogAdmin(); + const adminMode = catalogAdmin?.adminMode ?? false; + const { isFavoritePage, toggleFavoritePage } = useCatalogFavorites(); + const isFav = node ? isFavoritePage(node.pageId) : false; return ( -
- activateNode(node) }> - - { node.localization } +
+
activateNode(node) } + > +
+ +
+ { node.localization } + { adminMode && +
+ + { + e.stopPropagation(); + catalogAdmin.createPage({ + caption: 'New Page', + pageLayout: 'default_3x3', + minRank: 1, + visible: '1', + enabled: '1', + orderNum: 0, + parentId: node.pageId, + }); + } } + /> + + { + e.stopPropagation(); + if(confirm(LocalizeText('catalog.admin.delete.page.confirm', [ 'name' ], [ node.localization ]))) + { + catalogAdmin.deletePage(node.pageId); + } + } } + /> +
} + { !adminMode && node.pageId > 0 && + { e.stopPropagation(); toggleFavoritePage(node.pageId); } } + /> } { node.isBranch && - <> - { node.isOpen && } - { !node.isOpen && } - } - + + { node.isOpen ? : } + } +
{ node.isOpen && node.isBranch && }
diff --git a/src/components/catalog/views/navigation/CatalogNavigationView.tsx b/src/components/catalog/views/navigation/CatalogNavigationView.tsx index 10e2a2f..777c5fd 100644 --- a/src/components/catalog/views/navigation/CatalogNavigationView.tsx +++ b/src/components/catalog/views/navigation/CatalogNavigationView.tsx @@ -1,8 +1,6 @@ import { FC } from 'react'; import { ICatalogNode } from '../../../../api'; -import { AutoGrid, Column } from '../../../../common'; import { useCatalog } from '../../../../hooks'; -import { CatalogSearchView } from '../page/common/CatalogSearchView'; import { CatalogNavigationItemView } from './CatalogNavigationItemView'; import { CatalogNavigationSetView } from './CatalogNavigationSetView'; @@ -17,18 +15,13 @@ export const CatalogNavigationView: FC = props => const { searchResult = null } = useCatalog(); return ( - <> - - - - { searchResult && (searchResult.filteredNodes.length > 0) && searchResult.filteredNodes.map((n, index) => - { - return ; - }) } - { !searchResult && - } - - - +
+ { searchResult && (searchResult.filteredNodes.length > 0) && searchResult.filteredNodes.map((n, index) => + { + return ; + }) } + { !searchResult && + } +
); }; diff --git a/src/components/catalog/views/page/common/CatalogGridOfferView.tsx b/src/components/catalog/views/page/common/CatalogGridOfferView.tsx index f99bbad..a3fcedd 100644 --- a/src/components/catalog/views/page/common/CatalogGridOfferView.tsx +++ b/src/components/catalog/views/page/common/CatalogGridOfferView.tsx @@ -1,8 +1,9 @@ import { MouseEventType } from '@nitrots/nitro-renderer'; import { FC, MouseEvent, useMemo, useState } from 'react'; +import { FaHeart } from 'react-icons/fa'; import { IPurchasableOffer, Offer, ProductTypeEnum } from '../../../../../api'; import { LayoutAvatarImageView, LayoutGridItem, LayoutGridItemProps } from '../../../../../common'; -import { useCatalog, useInventoryFurni } from '../../../../../hooks'; +import { useCatalog, useCatalogFavorites, useInventoryFurni } from '../../../../../hooks'; interface CatalogGridOfferViewProps extends LayoutGridItemProps { @@ -16,6 +17,8 @@ export const CatalogGridOfferView: FC = props => const [ isMouseDown, setMouseDown ] = useState(false); const { requestOfferToMover = null } = useCatalog(); const { isVisible = false } = useInventoryFurni(); + const { isFavoriteOffer, toggleFavoriteOffer } = useCatalogFavorites(); + const isFav = isFavoriteOffer(offer.offerId); const iconUrl = useMemo(() => { @@ -51,9 +54,28 @@ export const CatalogGridOfferView: FC = props => if(!product) return null; return ( - + { (offer.product.productType === ProductTypeEnum.ROBOT) && } +
{ e.stopPropagation(); e.preventDefault(); toggleFavoriteOffer(offer.offerId, offer.localizationName, iconUrl); } } + onMouseDown={ e => e.stopPropagation() } + > + +
); }; diff --git a/src/components/catalog/views/page/common/CatalogSearchView.tsx b/src/components/catalog/views/page/common/CatalogSearchView.tsx index dc3f34b..7cc30c7 100644 --- a/src/components/catalog/views/page/common/CatalogSearchView.tsx +++ b/src/components/catalog/views/page/common/CatalogSearchView.tsx @@ -2,11 +2,9 @@ import { GetSessionDataManager, IFurnitureData } from '@nitrots/nitro-renderer'; import { FC, useEffect, useState } from 'react'; import { FaSearch, FaTimes } from 'react-icons/fa'; import { CatalogPage, CatalogType, FilterCatalogNode, FurnitureOffer, GetOfferNodes, ICatalogNode, ICatalogPage, IPurchasableOffer, LocalizeText, PageLocalization, SearchResult } from '../../../../../api'; -import { Button, Flex } from '../../../../../common'; import { useCatalog } from '../../../../../hooks'; -import { NitroInput } from '../../../../../layout'; -export const CatalogSearchView: FC<{}> = props => +export const CatalogSearchView: FC<{}> = () => { const [ searchValue, setSearchValue ] = useState(''); 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 ]); return ( -
- - - - - - - - setSearchValue(event.target.value) } /> - - - - { (!searchValue || !searchValue.length) && - } - { searchValue && !!searchValue.length && - } +
+ + setSearchValue(e.target.value) } + /> + { searchValue && searchValue.length > 0 && + }
); }; diff --git a/src/components/catalog/views/page/layout/CatalogLayoutCustomPrefixView.tsx b/src/components/catalog/views/page/layout/CatalogLayoutCustomPrefixView.tsx index 0b7f904..65f9a21 100644 --- a/src/components/catalog/views/page/layout/CatalogLayoutCustomPrefixView.tsx +++ b/src/components/catalog/views/page/layout/CatalogLayoutCustomPrefixView.tsx @@ -1,7 +1,6 @@ import { PurchasePrefixComposer } from '@nitrots/nitro-renderer'; -import { createPortal } from 'react-dom'; 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 data from '@emoji-mart/data'; import Picker from '@emoji-mart/react'; @@ -32,32 +31,6 @@ export const CatalogLayoutCustomPrefixView: FC = props => const [ showIconPicker, setShowIconPicker ] = useState(false); const [ selectedEffect, setSelectedEffect ] = useState(''); const [ purchased, setPurchased ] = useState(false); - const pickerContainerRef = useRef(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(() => { @@ -104,7 +77,7 @@ export const CatalogLayoutCustomPrefixView: FC = props => setLetterColors(prev => ({ ...prev, [selectedLetterIndex]: color })); setCustomColorInput(color); - // Auto-advance to next letter + // Auto-avanza alla lettera successiva if(selectedLetterIndex < prefixText.length - 1) { const nextIdx = selectedLetterIndex + 1; @@ -194,12 +167,12 @@ export const CatalogLayoutCustomPrefixView: FC = props => { /* Text + Icon Row */ }
- +
= props =>
- +
@@ -241,14 +214,14 @@ export const CatalogLayoutCustomPrefixView: FC = props =>
- { /* Emoji Picker (emoji-mart) - portaled to body, no backdrop */ } - { showIconPicker && createPortal( + { /* Emoji Picker (emoji-mart) - fixed overlay */ } + { showIconPicker && ( <> -
setShowIconPicker(false) } /> -
+
setShowIconPicker(false) } /> +
{ setSelectedIcon(emoji.native); setShowIconPicker(false); } } theme="dark" previewPosition="none" @@ -261,13 +234,12 @@ export const CatalogLayoutCustomPrefixView: FC = props => set="native" />
- , - document.body + ) } { /* Effect Selector */ }
- +
{ PRESET_PREFIX_EFFECTS.map(fx => (
@@ -316,7 +288,7 @@ export const CatalogLayoutCustomPrefixView: FC = props =>
- Select a letter, then choose a color. Auto-advances. + { LocalizeText('catalog.prefix.color.hint') }
= props =>
{ colorMode === 'perLetter' && selectedLetterIndex !== null && - Selected letter: "{ prefixText[selectedLetterIndex] || '' }" + { LocalizeText('catalog.prefix.color.selected') } "{ prefixText[selectedLetterIndex] || '' }" } -
+
{ PRESET_COLORS.map((color, idx) => { const isActive = currentActiveColor === color; return (
handleColorSelect(color) } /> @@ -443,8 +410,8 @@ export const CatalogLayoutCustomPrefixView: FC = props =>
- Price: - 5 Credits + { LocalizeText('catalog.prefix.price') } + { LocalizeText('catalog.prefix.price.amount') }
diff --git a/src/components/catalog/views/page/layout/CatalogLayoutDefaultView.tsx b/src/components/catalog/views/page/layout/CatalogLayoutDefaultView.tsx index 50b2955..107b820 100644 --- a/src/components/catalog/views/page/layout/CatalogLayoutDefaultView.tsx +++ b/src/components/catalog/views/page/layout/CatalogLayoutDefaultView.tsx @@ -1,7 +1,9 @@ import { FC } from 'react'; -import { GetConfigurationValue, ProductTypeEnum } from '../../../../../api'; -import { Column, Flex, Grid, LayoutImage, Text } from '../../../../../common'; +import { FaEdit, FaPlus } from 'react-icons/fa'; +import { GetConfigurationValue, LocalizeText, ProductTypeEnum } from '../../../../../api'; +import { Text } from '../../../../../common'; import { useCatalog } from '../../../../../hooks'; +import { useCatalogAdmin } from '../../../CatalogAdminContext'; import { CatalogHeaderView } from '../../catalog-header/CatalogHeaderView'; import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView'; import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView'; @@ -16,46 +18,87 @@ export const CatalogLayoutDefaultView: FC = props => { const { page = null } = props; const { currentOffer = null, currentPage = null } = useCatalog(); + const catalogAdmin = useCatalogAdmin(); + const adminMode = catalogAdmin?.adminMode ?? false; return ( - <> - - - { GetConfigurationValue('catalog.headers') && - } - - - - { !currentOffer && - <> - { !!page.localization.getImage(1) && - } - - } - { currentOffer && - <> - - { (currentOffer.product.productType !== ProductTypeEnum.BADGE) && - <> - - - } - { (currentOffer.product.productType === ProductTypeEnum.BADGE) && } - - - - { currentOffer.localizationName } -
-
- -
- -
- -
- } -
-
- +
+ { /* Admin: quick actions */ } + { adminMode && !catalogAdmin.editingPageData && +
+ + +
} + + { /* Product detail card */ } + { currentOffer && +
+ { /* Preview area */ } +
+ { (currentOffer.product.productType !== ProductTypeEnum.BADGE) && + <> + + + } + { (currentOffer.product.productType === ProductTypeEnum.BADGE) && + } +
+ { /* Product info + purchase */ } +
+ { /* Title row */ } +
+
+ { currentOffer.localizationName } + { adminMode && + catalogAdmin.setEditingOffer(currentOffer) } + /> } +
+ { adminMode && +
+ ID: { currentOffer.product.productClassId } + Offer: { currentOffer.offerId } + { currentOffer.product.productType.toUpperCase() } +
} + +
+ { /* Price */ } + + { /* Spinner */ } + + { /* Actions */ } +
+ +
+
+
} + + { /* Welcome/description card */ } + { !currentOffer && +
+ { !!page.localization.getImage(1) && + } + +
} + + { /* Item grid */ } +
+ { GetConfigurationValue('catalog.headers') && + } + +
+
); }; diff --git a/src/components/catalog/views/page/layout/CatalogLayoutPets3View.tsx b/src/components/catalog/views/page/layout/CatalogLayoutPets3View.tsx index 8c2e085..caba81a 100644 --- a/src/components/catalog/views/page/layout/CatalogLayoutPets3View.tsx +++ b/src/components/catalog/views/page/layout/CatalogLayoutPets3View.tsx @@ -1,5 +1,5 @@ import { FC } from 'react'; -import { Column } from '../../../../../common'; +import { FaPaw } from 'react-icons/fa'; import { CatalogLayoutProps } from './CatalogLayout.types'; export const CatalogLayoutPets3View: FC = props => @@ -9,17 +9,28 @@ export const CatalogLayoutPets3View: FC = props => const imageUrl = page.localization.getImage(1); return ( - -
- { imageUrl && } -
+
+ { /* Header card */ } +
+ { imageUrl && } +
+
+ + +
+
- -
- -
-
+ + { /* Content */ } +
+
- + + { /* Footer */ } + { !!page.localization.getText(3) && +
+ +
} +
); }; diff --git a/src/components/catalog/views/page/layout/CatalogLayoutTrophiesView.tsx b/src/components/catalog/views/page/layout/CatalogLayoutTrophiesView.tsx index bcea3d6..d192b81 100644 --- a/src/components/catalog/views/page/layout/CatalogLayoutTrophiesView.tsx +++ b/src/components/catalog/views/page/layout/CatalogLayoutTrophiesView.tsx @@ -1,6 +1,10 @@ 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 { useCatalogAdmin } from '../../../CatalogAdminContext'; +import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView'; import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView'; import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView'; import { CatalogTotalPriceWidget } from '../widgets/CatalogTotalPriceWidget'; @@ -12,6 +16,8 @@ export const CatalogLayoutTrophiesView: FC = props => const { page = null } = props; const [ trophyText, setTrophyText ] = useState(''); const { currentOffer = null, setPurchaseOptions = null } = useCatalog(); + const catalogAdmin = useCatalogAdmin(); + const adminMode = catalogAdmin?.adminMode ?? false; useEffect(() => { @@ -27,30 +33,104 @@ export const CatalogLayoutTrophiesView: FC = props => }); }, [ currentOffer, trophyText, setPurchaseOptions ]); + const canPurchase = currentOffer && trophyText.trim().length > 0; + return ( - - - -