diff --git a/src/components/catalog/CatalogAdminContext.tsx b/src/components/catalog/CatalogAdminContext.tsx index 5a5fe18..762e3f8 100644 --- a/src/components/catalog/CatalogAdminContext.tsx +++ b/src/components/catalog/CatalogAdminContext.tsx @@ -1,8 +1,10 @@ -import { CatalogAdminCreateOfferComposer, CatalogAdminCreatePageComposer, CatalogAdminDeleteOfferComposer, CatalogAdminDeletePageComposer, CatalogAdminMoveOfferComposer, CatalogAdminMovePageComposer, CatalogAdminPublishComposer, CatalogAdminResultEvent, CatalogAdminSaveOfferComposer, CatalogAdminSavePageComposer } from '@nitrots/nitro-renderer'; +import { CatalogAdminCreateOfferComposer, CatalogAdminCreatePageComposer, CatalogAdminDeleteOfferComposer, CatalogAdminDeletePageComposer, CatalogAdminMoveOfferComposer, CatalogAdminMovePageComposer, CatalogAdminPublishComposer, CatalogAdminResultEvent, CatalogAdminSaveOfferComposer, CatalogAdminSavePageComposer, CatalogAdminSavePageImagesComposer } 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 type AdminManageTab = 'pages' | 'offers' | 'publish'; + export interface IPageEditData { pageId?: number; @@ -14,6 +16,7 @@ export interface IPageEditData minRank: number; clubOnly?: string; orderNum: number; + iconId?: number; pageHeadline?: string; pageTeaser?: string; pageSpecial?: string; @@ -45,6 +48,8 @@ interface ICatalogAdminContext { adminMode: boolean; setAdminMode: (value: boolean) => void; + activeManageTab: AdminManageTab; + setActiveManageTab: (tab: AdminManageTab) => void; editingOffer: IPurchasableOffer | null; setEditingOffer: (offer: IPurchasableOffer | null) => void; editingPageData: boolean; @@ -53,14 +58,23 @@ interface ICatalogAdminContext setEditingRootPage: (value: boolean) => void; editingPageNode: ICatalogNode | null; setEditingPageNode: (node: ICatalogNode | null) => void; + selectedOfferIds: Set; + toggleOfferSelection: (id: number, multi?: boolean) => void; + selectAllOffers: (ids: number[]) => void; + clearOfferSelection: () => void; + offerSearchQuery: string; + setOfferSearchQuery: (query: string) => void; loading: boolean; lastError: string | null; savePage: (data: IPageEditData) => void; createPage: (data: IPageEditData) => void; deletePage: (pageId: number) => void; + savePageImages: (pageId: number, headerImage: string, teaserImage: string) => void; saveOffer: (data: IOfferEditData) => void; createOffer: (data: IOfferEditData) => void; deleteOffer: (offerId: number) => void; + duplicateOffer: (offer: IPurchasableOffer, pageId: number) => void; + batchUpdateOfferPrices: (offerIds: number[], credits: number, points: number, pointsType: number, pageId: number) => void; reorderOffers: (orders: { id: number; orderNumber: number }[]) => void; reorderPage: (pageId: number, newParentId: number, newIndex: number) => void; togglePageEnabled: (pageId: number) => void; @@ -76,40 +90,38 @@ export const useCatalogAdmin = () => useContext(CatalogAdminContext); export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children }) => { const [ adminMode, setAdminMode ] = useState(false); + const [ activeManageTab, setActiveManageTab ] = useState('pages'); const [ editingOffer, setEditingOffer ] = useState(null); const [ editingPageData, setEditingPageData ] = useState(false); const [ editingRootPage, setEditingRootPage ] = useState(false); const [ editingPageNode, setEditingPageNode ] = useState(null); + const [ selectedOfferIds, setSelectedOfferIds ] = useState>(new Set()); + const [ offerSearchQuery, setOfferSearchQuery ] = useState(''); const [ loading, setLoading ] = useState(false); const [ lastError, setLastError ] = useState(null); const [ hasPendingChanges, setHasPendingChanges ] = useState(false); const pendingActionRef = useRef(null); const { simpleAlert = null } = useNotification(); - // Keyboard shortcuts: Esc to close edit panels - useEffect(() => + const toggleOfferSelection = useCallback((id: number, multi = false) => { - if(!adminMode) return; - - const handleKeyDown = (e: KeyboardEvent) => + setSelectedOfferIds(prev => { - if(e.key === 'Escape') - { - if(editingOffer) { setEditingOffer(null); e.preventDefault(); return; } - if(editingPageData || editingRootPage || editingPageNode) - { - setEditingPageData(false); - setEditingRootPage(false); - setEditingPageNode(null); - e.preventDefault(); - } - } - }; + const next = new Set(multi ? prev : []); + if(next.has(id)) next.delete(id); else next.add(id); + return next; + }); + }, []); - window.addEventListener('keydown', handleKeyDown); + const selectAllOffers = useCallback((ids: number[]) => + { + setSelectedOfferIds(new Set(ids)); + }, []); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [ adminMode, editingOffer, editingPageData, editingRootPage, editingPageNode ]); + const clearOfferSelection = useCallback(() => + { + setSelectedOfferIds(new Set()); + }, []); useMessageEvent(CatalogAdminResultEvent, (event: CatalogAdminResultEvent) => { @@ -151,6 +163,7 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children }) 'savePage': 'Page saved (publish to apply)', 'createPage': 'Page created (publish to apply)', 'deletePage': 'Page deleted (publish to apply)', + 'saveImages': 'Images saved (publish to apply)', 'saveOffer': 'Offer saved (publish to apply)', 'createOffer': 'Offer created (publish to apply)', 'deleteOffer': 'Offer deleted (publish to apply)', @@ -159,6 +172,7 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children }) 'toggleVisible': 'Visibility toggled (publish to apply)', 'movePage': 'Page moved (publish to apply)', 'publish': 'Catalog published! All users updated.', + 'batchPrice': 'Prices updated (publish to apply)', }; simpleAlert(messages[action] || 'Operation completed', NotificationAlertType.DEFAULT, null, null, 'Catalog Admin'); @@ -172,7 +186,7 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children }) setLastError(null); pendingActionRef.current = 'savePage'; SendMessageComposer(new CatalogAdminSavePageComposer( - data.pageId || 0, data.caption, data.caption, data.pageLayout, 0, + data.pageId || 0, data.caption, data.caption, data.pageLayout, data.iconId ?? 0, data.minRank, data.visible === '1', data.enabled === '1', data.orderNum, data.parentId, data.pageHeadline || '', data.pageTeaser || '', data.pageTextDetails || '' @@ -185,7 +199,7 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children }) setLastError(null); pendingActionRef.current = 'createPage'; SendMessageComposer(new CatalogAdminCreatePageComposer( - data.caption, data.caption, data.pageLayout, 0, + data.caption, data.caption, data.pageLayout, data.iconId ?? 0, data.minRank, data.visible === '1', data.enabled === '1', data.orderNum, data.parentId )); @@ -199,6 +213,14 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children }) SendMessageComposer(new CatalogAdminDeletePageComposer(pageId)); }, []); + const savePageImages = useCallback((pageId: number, headerImage: string, teaserImage: string) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'saveImages'; + SendMessageComposer(new CatalogAdminSavePageImagesComposer(pageId, headerImage, teaserImage)); + }, []); + const saveOffer = useCallback((data: IOfferEditData) => { setLoading(true); @@ -233,6 +255,34 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children }) SendMessageComposer(new CatalogAdminDeleteOfferComposer(offerId)); }, []); + const duplicateOffer = useCallback((offer: IPurchasableOffer, pageId: number) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'createOffer'; + SendMessageComposer(new CatalogAdminCreateOfferComposer( + pageId, offer.product?.productClassId || 0, + offer.localizationId || '', offer.priceInCredits, offer.priceInActivityPoints, offer.activityPointType, + offer.product?.productCount || 1, offer.clubLevel > 0 ? 1 : 0, offer.product?.extraParam || '', + true, -1, 0, 0 + )); + }, []); + + const batchUpdateOfferPrices = useCallback((offerIds: number[], credits: number, points: number, pointsType: number, pageId: number) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'batchPrice'; + + for(const offerId of offerIds) + { + SendMessageComposer(new CatalogAdminSaveOfferComposer( + offerId, pageId, 0, '', credits, points, pointsType, + 1, 0, '', true, -1, 0, 0 + )); + } + }, []); + const reorderOffers = useCallback((orders: { id: number; orderNumber: number }[]) => { setLoading(true); @@ -277,16 +327,52 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children }) SendMessageComposer(new CatalogAdminPublishComposer()); }, []); + // Keyboard shortcuts + 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(); + return; + } + if(selectedOfferIds.size > 0) { clearOfferSelection(); e.preventDefault(); return; } + } + + if(e.ctrlKey && e.shiftKey && e.key === 'P') + { + e.preventDefault(); + if(hasPendingChanges && !loading) publishCatalog(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + + return () => window.removeEventListener('keydown', handleKeyDown); + }, [ adminMode, editingOffer, editingPageData, editingRootPage, editingPageNode, selectedOfferIds, clearOfferSelection, hasPendingChanges, loading, publishCatalog ]); + return ( diff --git a/src/components/catalog/CatalogClassicView.tsx b/src/components/catalog/CatalogClassicView.tsx index e86b384..85d881b 100644 --- a/src/components/catalog/CatalogClassicView.tsx +++ b/src/components/catalog/CatalogClassicView.tsx @@ -1,12 +1,12 @@ 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 { FC, useCallback, useEffect, useRef, useState } from 'react'; +import { FaCog } from 'react-icons/fa'; import { GetConfigurationValue, LocalizeText } from '../../api'; -import { Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common'; +import { NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common'; import { useCatalog } from '../../hooks'; import { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext'; +import { CatalogAdminEditorView } from './views/admin/CatalogAdminEditorView'; 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'; @@ -19,54 +19,62 @@ const CatalogClassicViewInner: FC<{}> = () => 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; + // Resizable nav column + const [ navWidth, setNavWidth ] = useState(() => + { + try { const s = localStorage.getItem('catalog.classic.nav.width'); return s ? Math.min(350, Math.max(100, parseInt(s))) : 220; } + catch { return 220; } + }); + const isResizing = useRef(false); + + const handleResizeStart = useCallback((e: React.MouseEvent) => + { + e.preventDefault(); + isResizing.current = true; + const startX = e.clientX; + const startWidth = navWidth; + + const onMouseMove = (ev: MouseEvent) => + { + if(!isResizing.current) return; + setNavWidth(Math.min(300, Math.max(100, startWidth + (ev.clientX - startX)))); + }; + + const onMouseUp = () => + { + isResizing.current = false; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + setNavWidth(w => { try { localStorage.setItem('catalog.classic.nav.width', String(w)); } catch {} return w; }); + }; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }, [ navWidth ]); + 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 '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]); - } + if(parts.length === 4 && parts[2] === 'offerId') { openPageByOfferId(parseInt(parts[3])); return; } + else { openPageByName(parts[2]); } } - else - { - setIsVisible(true); - } - + else { setIsVisible(true); } return; } }, @@ -74,7 +82,6 @@ const CatalogClassicViewInner: FC<{}> = () => }; AddLinkEventTracker(linkTracker); - return () => RemoveLinkEventTracker(linkTracker); }, [ setIsVisible, openPageByOfferId, openPageByName ]); @@ -83,89 +90,46 @@ const CatalogClassicViewInner: FC<{}> = () => { isVisible && setIsVisible(false) } /> - { /* Admin banner */ } - { adminMode && -
- ⚙ Admin Mode - -
} { rootNode && (rootNode.children.length > 0) && rootNode.children.map((child, index) => { - if(!adminMode && !child.isVisible) return null; - - const isHidden = !child.isVisible; + if(!child.isVisible) return null; return ( { if(searchResult) setSearchResult(null); - activateNode(child); } } > -
+
{ GetConfigurationValue('catalog.tab.icons') && } { child.localization } - { adminMode && isHidden && } - { adminMode && -
e.stopPropagation() }> - { catalogAdmin.setEditingPageNode(child); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true); } } /> - catalogAdmin.togglePageVisible(child.pageId) }> - { isHidden ? : } - - { if(confirm(LocalizeText('catalog.admin.delete.category.confirm', [ 'name' ], [ child.localization ]))) catalogAdmin.deletePage(child.pageId); } } /> -
}
); }) } - { /* Admin toggle button in tabs bar */ } { isMod && setAdminMode(!adminMode) }> } - { /* Admin: add new root category */ } - { adminMode && rootNode && -
- - -
} - - { !navigationHidden && - - { activeNodes && (activeNodes.length > 0) && - } - } - - { adminMode && } +
+ { !navigationHidden && activeNodes && activeNodes.length > 0 && + <> +
+ +
+
+ } +
{ GetCatalogLayout(currentPage, () => setNavigationHidden(true)) } - - +
+
} + { /* External windows */ } + diff --git a/src/components/catalog/CatalogModernView.tsx b/src/components/catalog/CatalogModernView.tsx index 95368b0..14c53a7 100644 --- a/src/components/catalog/CatalogModernView.tsx +++ b/src/components/catalog/CatalogModernView.tsx @@ -1,12 +1,12 @@ 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 { FC, useCallback, useEffect, useRef, useState } from 'react'; +import { FaCog, FaHeart, FaStar } 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 { CatalogAdminEditorView } from './views/admin/CatalogAdminEditorView'; 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'; @@ -21,57 +21,65 @@ const CatalogModernViewInner: FC<{}> = () => 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; + // Resizable nav column + const [ navWidth, setNavWidth ] = useState(() => + { + try { const s = localStorage.getItem('catalog.nav.width'); return s ? Math.min(400, Math.max(140, parseInt(s))) : 280; } + catch { return 280; } + }); + const isResizing = useRef(false); + + const handleResizeStart = useCallback((e: React.MouseEvent) => + { + e.preventDefault(); + isResizing.current = true; + const startX = e.clientX; + const startWidth = navWidth; + + const onMouseMove = (ev: MouseEvent) => + { + if(!isResizing.current) return; + setNavWidth(Math.min(350, Math.max(140, startWidth + (ev.clientX - startX)))); + }; + + const onMouseUp = () => + { + isResizing.current = false; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + setNavWidth(w => { try { localStorage.setItem('catalog.nav.width', String(w)); } catch {} return w; }); + }; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }, [ navWidth ]); + 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 '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]); - } + if(parts.length === 4 && parts[2] === 'offerId') { openPageByOfferId(parseInt(parts[3])); return; } + else { openPageByName(parts[2]); } } - else - { - setIsVisible(true); - } - + else { setIsVisible(true); } return; } }, @@ -79,7 +87,6 @@ const CatalogModernViewInner: FC<{}> = () => }; AddLinkEventTracker(linkTracker); - return () => RemoveLinkEventTracker(linkTracker); }, [ setIsVisible, openPageByOfferId, openPageByName ]); @@ -89,73 +96,33 @@ const CatalogModernViewInner: FC<{}> = () => setIsVisible(false) } /> - { /* Admin banner */ } - { adminMode && -
- ⚙ Admin Mode - -
} -
{ /* === LEFT SIDEBAR === */ }
- - { /* Favorites toggle */ } + { /* Favorites */ }
setShowFavorites(!showFavorites) } >
0 ? 'text-danger' : 'text-muted' }` } /> - { totalFavs > 0 && - - { totalFavs } - } + { totalFavs > 0 && { totalFavs } }
{ LocalizeText('catalog.favorites') }
- { /* Admin: root page actions */ } - { adminMode && rootNode && -
- - -
} - - { /* Category icons */ } + { /* Categories */ } { rootNode && rootNode.children.length > 0 && rootNode.children.map((child, index) => { - if(!adminMode && !child.isVisible) return null; - - const isHidden = !child.isVisible; + if(!child.isVisible) return null; return (
{ if(searchResult) setSearchResult(null); @@ -163,57 +130,12 @@ const CatalogModernViewInner: FC<{}> = () => 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); - } - } } - > - -
-
}
); }) } @@ -221,15 +143,14 @@ const CatalogModernViewInner: FC<{}> = () => { /* === MAIN AREA === */ }
- { /* Toolbar: search + admin */ } -
- { /* Breadcrumb */ } + { /* Toolbar */ } +
{ activeNodes && activeNodes.length > 0 ? activeNodes.map((node, i) => ( - { i > 0 && } + { i > 0 && { '\u203A' } } activateNode(node) : undefined }> { node.localization } @@ -238,11 +159,7 @@ const CatalogModernViewInner: FC<{}> = () => )) : { LocalizeText('catalog.title') } }
- -
- -
- +
{ isMod && }
- { /* Content area */ } + { /* Content */ }
{ showFavorites - ?
- setShowFavorites(false) } /> -
+ ?
setShowFavorites(false) } />
: <> { !navigationHidden && activeNodes && activeNodes.length > 0 && -
- -
} + <> +
+ +
+
+ }
- { adminMode && } { GetCatalogLayout(currentPage, () => setNavigationHidden(true)) }
} @@ -274,6 +191,8 @@ const CatalogModernViewInner: FC<{}> = () =>
} + { /* External windows */ } + diff --git a/src/components/catalog/views/admin/CatalogAdminEditorView.tsx b/src/components/catalog/views/admin/CatalogAdminEditorView.tsx new file mode 100644 index 0000000..a5e3831 --- /dev/null +++ b/src/components/catalog/views/admin/CatalogAdminEditorView.tsx @@ -0,0 +1,75 @@ +import { FC } from 'react'; +import { FaBoxOpen, FaCloudUploadAlt, FaSitemap } from 'react-icons/fa'; +import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common'; +import { AdminManageTab, useCatalogAdmin } from '../../CatalogAdminContext'; +import { CatalogAdminOfferPanel } from './CatalogAdminOfferPanel'; +import { CatalogAdminPagePanel } from './CatalogAdminPagePanel'; +import { CatalogAdminPublishPanel } from './CatalogAdminPublishPanel'; + +const TABS: { key: AdminManageTab; label: string; icon: FC<{ className?: string }> }[] = [ + { key: 'pages', label: 'Pages', icon: FaSitemap }, + { key: 'offers', label: 'Offers', icon: FaBoxOpen }, + { key: 'publish', label: 'Publish', icon: FaCloudUploadAlt }, +]; + +export const CatalogAdminEditorView: FC<{}> = () => +{ + const catalogAdmin = useCatalogAdmin(); + const adminMode = catalogAdmin?.adminMode ?? false; + const setAdminMode = catalogAdmin?.setAdminMode; + const activeTab = catalogAdmin?.activeManageTab ?? 'pages'; + const setActiveTab = catalogAdmin?.setActiveManageTab; + const hasPendingChanges = catalogAdmin?.hasPendingChanges ?? false; + const loading = catalogAdmin?.loading ?? false; + const publishCatalog = catalogAdmin?.publishCatalog; + + if(!adminMode) return null; + + return ( + + setAdminMode(false) } /> + + { /* Tab bar */ } +
+ { TABS.map(tab => + { + const Icon = tab.icon; + const isActive = activeTab === tab.key; + + return ( + + ); + }) } + +
+ + { hasPendingChanges && + } +
+ + + { activeTab === 'pages' && } + { activeTab === 'offers' && } + { activeTab === 'publish' && } + + + ); +}; diff --git a/src/components/catalog/views/admin/CatalogAdminIconBrowser.tsx b/src/components/catalog/views/admin/CatalogAdminIconBrowser.tsx new file mode 100644 index 0000000..f8a09b0 --- /dev/null +++ b/src/components/catalog/views/admin/CatalogAdminIconBrowser.tsx @@ -0,0 +1,191 @@ +import { FC, useCallback, useEffect, useRef, useState } from 'react'; +import { FaSearch, FaSpinner, FaTimes } from 'react-icons/fa'; +import { LocalizeText } from '../../../../api'; +import { CatalogIconView } from '../catalog-icon/CatalogIconView'; + +export interface CatalogAdminIconBrowserProps +{ + currentIconId: number; + onSelect: (iconId: number) => void; + onClose: () => void; +} + +interface IconResult +{ + icons: number[]; + total: number; + offset: number; + limit: number; +} + +export const CatalogAdminIconBrowser: FC = props => +{ + const { currentIconId, onSelect, onClose } = props; + + const [ icons, setIcons ] = useState([]); + const [ total, setTotal ] = useState(0); + const [ offset, setOffset ] = useState(0); + const [ search, setSearch ] = useState(''); + const [ loading, setLoading ] = useState(false); + const [ selected, setSelected ] = useState(null); + const debounceRef = useRef>(null); + const limit = 120; + + const fetchIcons = useCallback(async (searchQuery: string, newOffset: number, append: boolean) => + { + setLoading(true); + + try + { + const params = new URLSearchParams({ limit: String(limit), offset: String(newOffset) }); + + if(searchQuery) params.set('search', searchQuery); + + const res = await fetch(`/api/admin/catalog/icons?${ params.toString() }`); + const data: IconResult = await res.json(); + + setIcons(prev => append ? [ ...prev, ...data.icons ] : data.icons); + setTotal(data.total); + setOffset(newOffset); + } + catch(e) + { + console.error('Failed to fetch catalog icons', e); + } + finally + { + setLoading(false); + } + }, []); + + useEffect(() => + { + fetchIcons('', 0, false); + }, [ fetchIcons ]); + + const handleSearchChange = useCallback((value: string) => + { + setSearch(value); + + if(debounceRef.current) clearTimeout(debounceRef.current); + + debounceRef.current = setTimeout(() => + { + setSelected(null); + fetchIcons(value, 0, false); + }, 300); + }, [ fetchIcons ]); + + const handleLoadMore = useCallback(() => + { + fetchIcons(search, offset + limit, true); + }, [ offset, search, fetchIcons ]); + + const handleConfirm = useCallback(() => + { + if(selected !== null) onSelect(selected); + }, [ selected, onSelect ]); + + const hasMore = icons.length < total; + + return ( +
+
+ +
e.stopPropagation() }> + { /* Header */ } +
+ + Choose Icon + +
+ +
+
+ + { /* Search */ } +
+
+ + handleSearchChange(e.target.value) } + /> +
+ { total } icons +
+ + { /* Grid */ } +
+ { icons.length === 0 && !loading + ?
+ { search ? 'No icons found' : 'No icons available' } +
+ :
+ { icons.map(id => + { + const isSelected = selected === id; + const isCurrent = currentIconId === id; + + return ( +
setSelected(id) } + onDoubleClick={ () => onSelect(id) } + > + + { id } + { isCurrent && } +
+ ); + }) } +
} + + { loading && +
+ +
} + + { hasMore && !loading && +
+ +
} +
+ + { /* Footer */ } +
+
+ { selected !== null ? `Icon #${ selected }` : 'Click an icon to select' } +
+
+ + +
+
+
+
+ ); +}; diff --git a/src/components/catalog/views/admin/CatalogAdminImageBrowser.tsx b/src/components/catalog/views/admin/CatalogAdminImageBrowser.tsx new file mode 100644 index 0000000..004db3a --- /dev/null +++ b/src/components/catalog/views/admin/CatalogAdminImageBrowser.tsx @@ -0,0 +1,216 @@ +import { FC, useCallback, useEffect, useRef, useState } from 'react'; +import { FaSearch, FaSpinner, FaTimes } from 'react-icons/fa'; +import { GetConfigurationValue, LocalizeText } from '../../../../api'; + +export interface CatalogAdminImageBrowserProps +{ + type: 'header' | 'teaser'; + currentImage?: string; + onSelect: (filename: string) => void; + onClose: () => void; +} + +interface ImageResult +{ + images: string[]; + total: number; + offset: number; + limit: number; +} + +export const CatalogAdminImageBrowser: FC = props => +{ + const { type, currentImage, onSelect, onClose } = props; + + const [ images, setImages ] = useState([]); + const [ total, setTotal ] = useState(0); + const [ offset, setOffset ] = useState(0); + const [ search, setSearch ] = useState(''); + const [ loading, setLoading ] = useState(false); + const [ selected, setSelected ] = useState(null); + const debounceRef = useRef>(null); + const limit = 80; + + const buildImageUrl = useCallback((name: string) => + { + const assetUrl = GetConfigurationValue('catalog.asset.image.url'); + + return assetUrl.replace('%name%', name); + }, []); + + const fetchImages = useCallback(async (searchQuery: string, newOffset: number, append: boolean) => + { + setLoading(true); + + try + { + const params = new URLSearchParams({ type, limit: String(limit), offset: String(newOffset) }); + + if(searchQuery) params.set('search', searchQuery); + + const res = await fetch(`/api/admin/catalog/images?${ params.toString() }`); + const data: ImageResult = await res.json(); + + setImages(prev => append ? [ ...prev, ...data.images ] : data.images); + setTotal(data.total); + setOffset(newOffset); + } + catch(e) + { + console.error('Failed to fetch catalog images', e); + } + finally + { + setLoading(false); + } + }, [ type ]); + + useEffect(() => + { + fetchImages('', 0, false); + }, [ fetchImages ]); + + const handleSearchChange = useCallback((value: string) => + { + setSearch(value); + + if(debounceRef.current) clearTimeout(debounceRef.current); + + debounceRef.current = setTimeout(() => + { + setSelected(null); + fetchImages(value, 0, false); + }, 300); + }, [ fetchImages ]); + + const handleLoadMore = useCallback(() => + { + const newOffset = offset + limit; + + fetchImages(search, newOffset, true); + }, [ offset, search, fetchImages ]); + + const handleSelect = useCallback((name: string) => + { + setSelected(name); + }, []); + + const handleConfirm = useCallback(() => + { + if(selected) onSelect(selected); + }, [ selected, onSelect ]); + + const handleDoubleClick = useCallback((name: string) => + { + onSelect(name); + }, [ onSelect ]); + + const hasMore = images.length < total; + + return ( +
+
+ +
e.stopPropagation() }> + { /* Header */ } +
+ + { type === 'header' ? LocalizeText('catalog.admin.browse.header') : LocalizeText('catalog.admin.browse.teaser') } + +
+ +
+
+ + { /* Search */ } +
+
+ + handleSearchChange(e.target.value) } + /> +
+ { total } { LocalizeText('catalog.admin.images.found') } +
+ + { /* Grid */ } +
+ { images.length === 0 && !loading + ?
+ { search ? LocalizeText('catalog.admin.images.noresults') : LocalizeText('catalog.admin.images.empty') } +
+ :
+ { images.map(name => + { + const url = buildImageUrl(name); + const isSelected = selected === name; + const isCurrent = currentImage && currentImage.includes(name); + + return ( +
handleSelect(name) } + onDoubleClick={ () => handleDoubleClick(name) } + > + { { (e.target as HTMLImageElement).style.display = 'none'; } } + /> + { name } + { isCurrent && current } +
+ ); + }) } +
} + + { loading && +
+ +
} + + { hasMore && !loading && +
+ +
} +
+ + { /* Footer */ } +
+
+ { selected ? selected : LocalizeText('catalog.admin.images.selecthint') } +
+
+ + +
+
+
+
+ ); +}; diff --git a/src/components/catalog/views/admin/CatalogAdminOfferEditView.tsx b/src/components/catalog/views/admin/CatalogAdminOfferEditView.tsx index f6da55a..06bd3ae 100644 --- a/src/components/catalog/views/admin/CatalogAdminOfferEditView.tsx +++ b/src/components/catalog/views/admin/CatalogAdminOfferEditView.tsx @@ -104,7 +104,7 @@ export const CatalogAdminOfferEditView: FC<{}> = () => 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'; + const inputClass = 'text-[13px] 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) }> @@ -124,30 +124,30 @@ export const CatalogAdminOfferEditView: FC<{}> = () =>
{ /* Current name */ } { !isNew && -
+
{ editingOffer.localizationName }
} { /* Catalog Name */ }
- + setCatalogName(e.target.value) } />
{ /* Generale */ }
-
{ LocalizeText('catalog.admin.offer.general') }
+
{ LocalizeText('catalog.admin.offer.general') }
- + setItemIds(e.target.value) } />
- + setAmount(parseInt(e.target.value) || 1) } />
- + setOrderNumber(parseInt(e.target.value) || 0) } />
@@ -155,18 +155,18 @@ export const CatalogAdminOfferEditView: FC<{}> = () => { /* Prezzi */ }
-
{ LocalizeText('catalog.admin.offer.prices') }
+
{ LocalizeText('catalog.admin.offer.prices') }
- + setCostCredits(parseInt(e.target.value) || 0) } />
- + setCostPoints(parseInt(e.target.value) || 0) } />
- + setClubOnly(e.target.value) }>
- + setLimitedStack(parseInt(e.target.value) || 0) } />
- + setOfferIdGroup(parseInt(e.target.value) || -1) } />
- + setExtradata(e.target.value) } />
setHaveOffer(e.target.checked ? '1' : '0') } /> - +
{ /* Actions */ }
{ !isNew - ? :
} -
diff --git a/src/components/catalog/views/admin/CatalogAdminOfferForm.tsx b/src/components/catalog/views/admin/CatalogAdminOfferForm.tsx new file mode 100644 index 0000000..28e67fa --- /dev/null +++ b/src/components/catalog/views/admin/CatalogAdminOfferForm.tsx @@ -0,0 +1,206 @@ +import { FC, useCallback, useEffect, useState } from 'react'; +import { FaCopy, FaSave, FaSpinner, FaTrash } from 'react-icons/fa'; +import { IPurchasableOffer, LocalizeText } from '../../../../api'; +import { IOfferEditData, useCatalogAdmin } from '../../CatalogAdminContext'; + +export interface CatalogAdminOfferFormProps +{ + offer: IPurchasableOffer | null; + pageId: number; + isNew?: boolean; + onClose?: () => void; +} + +export const CatalogAdminOfferForm: FC = props => +{ + const { offer, pageId, isNew = false, onClose } = props; + const catalogAdmin = useCatalogAdmin(); + 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 [ offerIdGroup, setOfferIdGroup ] = useState(-1); + const [ limitedStack, setLimitedStack ] = useState(0); + const [ orderNumber, setOrderNumber ] = useState(0); + + useEffect(() => + { + if(!offer) return; + + if(isNew || offer.offerId === -1) + { + setItemIds('0'); setCatalogName(''); setCostCredits(0); setCostPoints(0); + setPointsType(0); setAmount(1); setClubOnly('0'); setExtradata(''); + setHaveOffer('1'); setOfferIdGroup(-1); setLimitedStack(0); setOrderNumber(0); + } + else + { + setItemIds(String(offer.product?.productClassId || 0)); + setCatalogName(offer.localizationId || ''); + setCostCredits(offer.priceInCredits); + setCostPoints(offer.priceInActivityPoints); + setPointsType(offer.activityPointType); + setAmount(offer.product?.productCount || 1); + setClubOnly(offer.clubLevel > 0 ? '1' : '0'); + setExtradata(offer.product?.extraParam || ''); + setHaveOffer('1'); setOfferIdGroup(offer.offerId || -1); + setLimitedStack(0); setOrderNumber(0); + } + }, [ offer, isNew ]); + + const handleSave = useCallback(() => + { + if(!catalogAdmin) return; + + const data: IOfferEditData = { + offerId: isNew ? undefined : offer?.offerId, + pageId, itemIds, catalogName, costCredits, costPoints, pointsType, + amount, clubOnly, extradata, haveOffer, offerId_group: offerIdGroup, + limitedStack, orderNumber, + }; + + if(isNew) catalogAdmin.createOffer(data); + else catalogAdmin.saveOffer(data); + }, [ catalogAdmin, offer, isNew, pageId, itemIds, catalogName, costCredits, costPoints, pointsType, amount, clubOnly, extradata, haveOffer, offerIdGroup, limitedStack, orderNumber ]); + + const handleDelete = useCallback(() => + { + if(isNew || !offer || !catalogAdmin?.deleteOffer) return; + if(!confirm(LocalizeText('catalog.admin.delete.offer.confirm'))) return; + catalogAdmin.deleteOffer(offer.offerId); + onClose?.(); + }, [ isNew, offer, catalogAdmin, onClose ]); + + const handleDuplicate = useCallback(() => + { + if(!offer || !catalogAdmin?.duplicateOffer) return; + catalogAdmin.duplicateOffer(offer, pageId); + }, [ offer, catalogAdmin, pageId ]); + + if(!offer) return null; + + const iconUrl = !isNew && offer.product?.getIconUrl?.(offer); + const inputClass = 'text-[13px] border-2 border-card-grid-item-border rounded px-2 py-1 bg-white focus:outline-none focus:border-primary transition-colors w-full'; + + return ( +
+ { /* Header with icon preview */ } +
+ { iconUrl && +
+ +
} +
+
+ { isNew ? 'New Offer' : `Offer #${ offer.offerId }` } +
+ { !isNew && offer.localizationName && +
{ offer.localizationName }
} +
+
+ + { /* Identity */ } +
+
Identity
+
+
+ + setCatalogName(e.target.value) } /> +
+
+
+ + setItemIds(e.target.value) } /> +
+
+ + setAmount(parseInt(e.target.value) || 1) } /> +
+
+ + setOrderNumber(parseInt(e.target.value) || 0) } /> +
+
+
+
+ + { /* Pricing */ } +
+
Pricing
+
+
+ + setCostCredits(parseInt(e.target.value) || 0) } /> +
+
+ + setCostPoints(parseInt(e.target.value) || 0) } /> +
+
+ + +
+
+
+ + { /* Options */ } +
+
Options
+
+
+ + +
+
+ + setLimitedStack(parseInt(e.target.value) || 0) } /> +
+
+ + setOfferIdGroup(parseInt(e.target.value) || -1) } /> +
+
+
+ + setExtradata(e.target.value) } /> +
+ +
+ + { /* Actions */ } +
+
+ { !isNew && + } + { !isNew && + } +
+ +
+
+ ); +}; diff --git a/src/components/catalog/views/admin/CatalogAdminOfferPanel.tsx b/src/components/catalog/views/admin/CatalogAdminOfferPanel.tsx new file mode 100644 index 0000000..b493c78 --- /dev/null +++ b/src/components/catalog/views/admin/CatalogAdminOfferPanel.tsx @@ -0,0 +1,206 @@ +import { FC, useCallback, useMemo, useState } from 'react'; +import { FaCheckSquare, FaDollarSign, FaExchangeAlt, FaPlus, FaSearch, FaStar, FaTrash } from 'react-icons/fa'; +import { IPurchasableOffer, LocalizeText } from '../../../../api'; +import { useCatalog } from '../../../../hooks'; +import { useCatalogAdmin } from '../../CatalogAdminContext'; +import { CatalogAdminOfferForm } from './CatalogAdminOfferForm'; + +export const CatalogAdminOfferPanel: FC<{}> = () => +{ + const { currentPage = null, activeNodes = [], rootNode = null } = useCatalog(); + const catalogAdmin = useCatalogAdmin(); + const selectedOfferIds = catalogAdmin?.selectedOfferIds ?? new Set(); + const toggleOfferSelection = catalogAdmin?.toggleOfferSelection; + const clearOfferSelection = catalogAdmin?.clearOfferSelection; + const offerSearchQuery = catalogAdmin?.offerSearchQuery ?? ''; + const setOfferSearchQuery = catalogAdmin?.setOfferSearchQuery; + + const [ editingOffer, setEditingOffer ] = useState(null); + const [ isNewOffer, setIsNewOffer ] = useState(false); + + // Batch pricing state + const [ showBatchPrice, setShowBatchPrice ] = useState(false); + const [ batchCredits, setBatchCredits ] = useState(0); + const [ batchPoints, setBatchPoints ] = useState(0); + const [ batchPointsType, setBatchPointsType ] = useState(0); + + const offers = currentPage?.offers ?? []; + const pageId = currentPage?.pageId ?? 0; + + const filteredOffers = useMemo(() => + { + if(!offerSearchQuery) return offers; + const q = offerSearchQuery.toLowerCase(); + return offers.filter(o => + o.localizationName?.toLowerCase().includes(q) || + o.localizationId?.toLowerCase().includes(q) || + String(o.offerId).includes(q) + ); + }, [ offers, offerSearchQuery ]); + + const handleSelectOffer = useCallback((offer: IPurchasableOffer, e: React.MouseEvent) => + { + if(e.ctrlKey || e.metaKey) + { + toggleOfferSelection?.(offer.offerId, true); + } + else + { + setEditingOffer(offer); + setIsNewOffer(false); + clearOfferSelection?.(); + } + }, [ toggleOfferSelection, clearOfferSelection ]); + + const handleNewOffer = useCallback(() => + { + setEditingOffer({ offerId: -1 } as IPurchasableOffer); + setIsNewOffer(true); + }, []); + + const handleBulkDelete = useCallback(() => + { + if(selectedOfferIds.size === 0) return; + if(!confirm(`Delete ${ selectedOfferIds.size } selected offer(s)?`)) return; + for(const id of selectedOfferIds) catalogAdmin?.deleteOffer(id); + clearOfferSelection?.(); + }, [ catalogAdmin, selectedOfferIds, clearOfferSelection ]); + + const handleBatchPriceApply = useCallback(() => + { + if(selectedOfferIds.size === 0) return; + catalogAdmin?.batchUpdateOfferPrices( + Array.from(selectedOfferIds), batchCredits, batchPoints, batchPointsType, pageId + ); + setShowBatchPrice(false); + clearOfferSelection?.(); + }, [ catalogAdmin, selectedOfferIds, batchCredits, batchPoints, batchPointsType, pageId, clearOfferSelection ]); + + const handleSelectAll = useCallback(() => + { + catalogAdmin?.selectAllOffers(offers.map(o => o.offerId)); + }, [ catalogAdmin, offers ]); + + const breadcrumb = activeNodes?.map(n => n.localization).join(' > ') || 'No page selected'; + const inputClass = 'text-[13px] border-2 border-card-grid-item-border rounded px-2 py-1 bg-white focus:outline-none focus:border-primary transition-colors w-full'; + + return ( +
+ { /* Left: Offer grid */ } +
+ { /* Top bar */ } +
+
+ + { breadcrumb } + ({ filteredOffers.length }) +
+ +
+ + setOfferSearchQuery(e.target.value) } + /> +
+ + +
+ + { /* Selection bar */ } + { selectedOfferIds.size > 0 && +
+ { selectedOfferIds.size } selected + + + + +
} + + { /* Batch price editor */ } + { showBatchPrice && selectedOfferIds.size > 0 && +
+ Set price for { selectedOfferIds.size } offers: + setBatchCredits(parseInt(e.target.value) || 0) } /> + setBatchPoints(parseInt(e.target.value) || 0) } /> + + +
} + + { /* Offer grid */ } +
+ { !currentPage + ?
+ Select a page from Browse tab to see its offers +
+ : filteredOffers.length === 0 + ?
+ { offerSearchQuery ? 'No matches' : 'No offers on this page' } +
+ :
+ { filteredOffers.map((offer, index) => + { + const isSelected = selectedOfferIds.has(offer.offerId); + const isEditing = editingOffer?.offerId === offer.offerId && !isNewOffer; + const iconUrl = offer.product?.getIconUrl?.(offer); + + return ( +
handleSelectOffer(offer, e) } + > +
+ toggleOfferSelection?.(offer.offerId, true) } + onClick={ e => e.stopPropagation() } + /> +
+ + { iconUrl + ? + :
?
} + + #{ offer.offerId } +
+ ); + }) } +
} +
+
+ + { /* Right: Offer edit form */ } +
+ { editingOffer + ? setEditingOffer(null) } /> + :
+ Click an offer to edit
Ctrl+click to multi-select +
} +
+
+ ); +}; diff --git a/src/components/catalog/views/admin/CatalogAdminPageEditView.tsx b/src/components/catalog/views/admin/CatalogAdminPageEditView.tsx index 6fd4a07..aa917a5 100644 --- a/src/components/catalog/views/admin/CatalogAdminPageEditView.tsx +++ b/src/components/catalog/views/admin/CatalogAdminPageEditView.tsx @@ -64,7 +64,7 @@ export const CatalogAdminPageEditView: FC<{}> = () => 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 inputClass = 'text-[13px] 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 () => { @@ -101,37 +101,37 @@ export const CatalogAdminPageEditView: FC<{}> = () => 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) } />
-