diff --git a/src/common/layout/LayoutBadgeImageView.tsx b/src/common/layout/LayoutBadgeImageView.tsx index 2486584..8b322c9 100644 --- a/src/common/layout/LayoutBadgeImageView.tsx +++ b/src/common/layout/LayoutBadgeImageView.tsx @@ -115,7 +115,11 @@ export const LayoutBadgeImageView: FC = props => { const element = await TextureUtils.generateImage(new NitroSprite(event.image)); - element.onload = () => setImageElement(element); + // The generated image carries an already-decoded data-URL, so + // `onload` may have fired before we attach it and never run. + // Set immediately when complete; otherwise wait for load. + if(element.complete && element.naturalWidth) setImageElement(element); + else element.onload = () => setImageElement(element); } else { @@ -143,7 +147,8 @@ export const LayoutBadgeImageView: FC = props => { const element = await TextureUtils.generateImage(new NitroSprite(texture)); - element.onload = () => setImageElement(element); + if(element.complete && element.naturalWidth) setImageElement(element); + else element.onload = () => setImageElement(element); })(); } else diff --git a/src/components/catalog-modern/CatalogAdminContext.tsx b/src/components/catalog-modern/CatalogAdminContext.tsx new file mode 100644 index 0000000..a0d70fb --- /dev/null +++ b/src/components/catalog-modern/CatalogAdminContext.tsx @@ -0,0 +1,304 @@ +import { CatalogAdminCreateOfferComposer, CatalogAdminCreatePageComposer, CatalogAdminDeleteOfferComposer, CatalogAdminDeletePageComposer, CatalogAdminMoveOfferComposer, CatalogAdminMovePageComposer, CatalogAdminPublishComposer, CatalogAdminResultEvent, CatalogAdminSaveOfferComposer, CatalogAdminSavePageComposer } from '@nitrots/nitro-renderer'; +import { createContext, FC, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { ICatalogNode, IPurchasableOffer, NotificationAlertType, SendMessageComposer } from '../../api'; +import { useCatalogUiState, useMessageEvent, useNotification } from '../../hooks'; + +export interface IPageEditData +{ + pageId?: number; + caption: string; + captionSave: string; + parentId: number; + catalogMode: string; + pageLayout: string; + iconImage: number; + enabled: string; + visible: string; + minRank: number; + clubOnly?: string; + orderNum: number; + pageHeadline?: string; + pageTeaser?: string; + pageSpecial?: string; + pageText1?: string; + pageText2?: string; + pageTextDetails?: string; + pageTextTeaser?: string; +} + +export interface IOfferEditData +{ + offerId?: number; + pageId: number; + itemIds: string; + catalogName: string; + costCredits: number; + costPoints: number; + pointsType: number; + amount: number; + clubOnly: string; + extradata: string; + haveOffer: string; + offerId_group: number; + limitedStack: number; + orderNumber: number; +} + +interface ICatalogAdminContext +{ + adminMode: boolean; + setAdminMode: (value: boolean) => void; + editingOffer: IPurchasableOffer | null; + setEditingOffer: (offer: IPurchasableOffer | null) => void; + editingPageData: boolean; + setEditingPageData: (value: boolean) => void; + editingRootPage: boolean; + setEditingRootPage: (value: boolean) => void; + editingPageNode: ICatalogNode | null; + setEditingPageNode: (node: ICatalogNode | null) => void; + loading: boolean; + lastError: string | null; + savePage: (data: IPageEditData) => void; + createPage: (data: IPageEditData) => void; + deletePage: (pageId: number) => void; + saveOffer: (data: IOfferEditData) => void; + createOffer: (data: IOfferEditData) => void; + deleteOffer: (offerId: number) => void; + reorderOffers: (orders: { id: number; orderNumber: number }[]) => void; + reorderPage: (pageId: number, newParentId: number, newIndex: number) => void; + togglePageEnabled: (pageId: number) => void; + togglePageVisible: (pageId: number) => void; + publishCatalog: () => void; + hasPendingChanges: boolean; +} + +const CatalogAdminContext = createContext(null); + +export const useCatalogAdmin = () => useContext(CatalogAdminContext); + +export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children }) => +{ + const { currentType } = useCatalogUiState(); + const [ adminMode, setAdminMode ] = useState(false); + const [ editingOffer, setEditingOffer ] = useState(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 [ hasPendingChanges, setHasPendingChanges ] = useState(false); + const pendingActionRef = useRef(null); + const { simpleAlert = null } = useNotification(); + + useEffect(() => + { + if(!adminMode) return; + + const handleKeyDown = (e: KeyboardEvent) => + { + if(e.key === 'Escape') + { + if(editingOffer) + { + setEditingOffer(null); e.preventDefault(); return; + } + if(editingPageData || editingRootPage || editingPageNode) + { + setEditingPageData(false); + setEditingRootPage(false); + setEditingPageNode(null); + e.preventDefault(); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + + return () => window.removeEventListener('keydown', handleKeyDown); + }, [ adminMode, editingOffer, editingPageData, editingRootPage, editingPageNode ]); + + useMessageEvent(CatalogAdminResultEvent, (event: CatalogAdminResultEvent) => + { + const parser = event.getParser(); + const action = pendingActionRef.current; + + pendingActionRef.current = null; + setLoading(false); + + if(!parser.success) + { + setLastError(parser.message || 'Operation failed'); + + if(simpleAlert) + { + simpleAlert(parser.message || 'Operation failed', NotificationAlertType.ALERT, null, null, 'Admin Error'); + } + } + else + { + setLastError(null); + setEditingOffer(null); + setEditingPageData(false); + setEditingRootPage(false); + setEditingPageNode(null); + + if(action === 'publish') + { + setHasPendingChanges(false); + } + else + { + setHasPendingChanges(true); + } + + if(simpleAlert && action) + { + const messages: Record = { + 'savePage': 'Page saved (publish to apply)', + 'createPage': 'Page created (publish to apply)', + 'deletePage': 'Page deleted (publish to apply)', + 'saveOffer': 'Offer saved (publish to apply)', + 'createOffer': 'Offer created (publish to apply)', + 'deleteOffer': 'Offer deleted (publish to apply)', + 'reorder': 'Order updated (publish to apply)', + 'toggleEnabled': 'Page toggled (publish to apply)', + 'toggleVisible': 'Visibility toggled (publish to apply)', + 'movePage': 'Page moved (publish to apply)', + 'publish': 'Catalog published! All users updated.', + }; + + simpleAlert(messages[action] || 'Operation completed', NotificationAlertType.DEFAULT, null, null, 'Catalog Admin'); + } + } + }); + + const savePage = useCallback((data: IPageEditData) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'savePage'; + + SendMessageComposer(new CatalogAdminSavePageComposer( + data.pageId || 0, data.caption, data.captionSave, data.pageLayout, data.iconImage, + data.minRank, data.visible === '1', data.enabled === '1', + data.orderNum, data.parentId, + data.pageHeadline || '', data.pageTeaser || '', data.pageTextDetails || '', currentType, data.catalogMode, + data.pageText1 || '' + )); + }, [ currentType ]); + + const createPage = useCallback((data: IPageEditData) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'createPage'; + SendMessageComposer(new CatalogAdminCreatePageComposer( + data.caption, data.captionSave, data.pageLayout, data.iconImage, + data.minRank, data.visible === '1', data.enabled === '1', + data.orderNum, data.parentId, currentType, data.catalogMode + )); + }, [ currentType ]); + + const deletePage = useCallback((pageId: number) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'deletePage'; + SendMessageComposer(new CatalogAdminDeletePageComposer(pageId, currentType)); + }, [ currentType ]); + + const saveOffer = useCallback((data: IOfferEditData) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'saveOffer'; + SendMessageComposer(new CatalogAdminSaveOfferComposer( + data.offerId || 0, data.pageId, data.itemIds || '', + data.catalogName, data.costCredits, data.costPoints, data.pointsType, + data.amount, data.clubOnly === '1' ? 1 : 0, data.extradata, + data.haveOffer === '1', data.offerId_group, data.limitedStack, data.orderNumber, currentType + )); + }, [ currentType ]); + + const createOffer = useCallback((data: IOfferEditData) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'createOffer'; + SendMessageComposer(new CatalogAdminCreateOfferComposer( + data.pageId, data.itemIds || '', + data.catalogName, data.costCredits, data.costPoints, data.pointsType, + data.amount, data.clubOnly === '1' ? 1 : 0, data.extradata, + data.haveOffer === '1', data.offerId_group, data.limitedStack, data.orderNumber, currentType + )); + }, [ currentType ]); + + const deleteOffer = useCallback((offerId: number) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'deleteOffer'; + SendMessageComposer(new CatalogAdminDeleteOfferComposer(offerId, currentType)); + }, [ currentType ]); + + const reorderOffers = useCallback((orders: { id: number; orderNumber: number }[]) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'reorder'; + + for(const order of orders) + { + SendMessageComposer(new CatalogAdminMoveOfferComposer(order.id, order.orderNumber, currentType)); + } + }, [ currentType ]); + + const reorderPage = useCallback((pageId: number, newParentId: number, newIndex: number) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'movePage'; + SendMessageComposer(new CatalogAdminMovePageComposer(pageId, newParentId, newIndex, currentType)); + }, [ currentType ]); + + const togglePageEnabled = useCallback((pageId: number) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'toggleEnabled'; + SendMessageComposer(new CatalogAdminMovePageComposer(pageId, -1, -1, currentType)); + }, [ currentType ]); + + const togglePageVisible = useCallback((pageId: number) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'toggleVisible'; + SendMessageComposer(new CatalogAdminMovePageComposer(pageId, -2, -1, currentType)); + }, [ currentType ]); + + const publishCatalog = useCallback(() => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'publish'; + SendMessageComposer(new CatalogAdminPublishComposer()); + }, []); + + return ( + + { children } + + ); +}; diff --git a/src/components/catalog-modern/CatalogModernView.tsx b/src/components/catalog-modern/CatalogModernView.tsx new file mode 100644 index 0000000..ea1c2de --- /dev/null +++ b/src/components/catalog-modern/CatalogModernView.tsx @@ -0,0 +1,335 @@ +import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; +import { FC, useEffect, useState } from 'react'; +import { FaCog, FaEdit, FaEye, FaEyeSlash, FaHeart, FaPlus, FaStar, FaTrash } from 'react-icons/fa'; +import { CatalogType, LocalizeText } from '../../api'; +import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common'; +import { useCatalogActions, useCatalogData, useCatalogFavorites, useCatalogUiState, useHasPermission } from '../../hooks'; +import { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext'; +import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView'; +import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView'; +import { CatalogBuildersClubStatusView } from './views/catalog-header/CatalogBuildersClubStatusView'; +import { CatalogIconView } from './views/catalog-icon/CatalogIconView'; +import { CatalogFavoritesView } from './views/favorites/CatalogFavoritesView'; +import { CatalogGiftView } from './views/gift/CatalogGiftView'; +import { CatalogNavigationView } from './views/navigation/CatalogNavigationView'; +import { CatalogSearchView } from './views/page/common/CatalogSearchView'; +import { GetCatalogLayout } from './views/page/layout/GetCatalogLayout'; +import { MarketplacePostOfferView } from './views/page/layout/marketplace/MarketplacePostOfferView'; + +const CatalogModernViewInner: FC<{}> = () => +{ + const { rootNode = null, currentPage = null, searchResult = null } = useCatalogData(); + const { isVisible = false, setIsVisible = null, navigationHidden = false, setNavigationHidden = null, activeNodes = [], setSearchResult = null, currentType = CatalogType.NORMAL } = useCatalogUiState(); + const { openPageByName = null, openPageByOfferId = null, activateNode = null, openCatalogByType = null, toggleCatalogByType = null } = useCatalogActions(); + const catalogAdmin = useCatalogAdmin(); + const adminMode = catalogAdmin?.adminMode ?? false; + const setAdminMode = catalogAdmin?.setAdminMode ?? (() => + {}); + const hasPendingChanges = catalogAdmin?.hasPendingChanges ?? false; + const publishCatalog = catalogAdmin?.publishCatalog ?? (() => + {}); + const loading = catalogAdmin?.loading ?? false; + const { favoriteOfferIds, favoritePageIds } = useCatalogFavorites(); + const [ showFavorites, setShowFavorites ] = useState(false); + + const isMod = useHasPermission('acc_catalogfurni'); + const totalFavs = favoriteOfferIds.length + favoritePageIds.length; + const buildersClubHeaderStyle = (currentType === CatalogType.BUILDER) + ? { borderColor: '#d79d2e', borderBottomColor: '#000', background: 'linear-gradient(180deg, #d89f2d 0%, #c68515 100%)' } + : undefined; + // Desktop = fixed 780x520. On mobile the window clamps below the viewport so + // it reads as a dialog (with margins) instead of filling the whole phone + // screen — applies to both the normal catalog and the Builders Club. + const catalogCardSize = 'w-[780px] h-[520px] max-w-[96vw] max-h-[72vh] sm:max-w-[100vw] sm:max-h-[92vh]'; + + useEffect(() => + { + const getCatalogTypeFromLink = (type?: string) => + { + switch((type || '').toLowerCase()) + { + case 'bc': + case 'builder': + case 'buildersclub': + case 'builders_club': + return CatalogType.BUILDER; + default: + return CatalogType.NORMAL; + } + }; + + const linkTracker: ILinkEventTracker = { + linkReceived: (url: string) => + { + const parts = url.split('/'); + + if(parts.length < 2) return; + + switch(parts[1]) + { + case 'show': + if(parts.length > 2) + { + openCatalogByType(getCatalogTypeFromLink(parts[2])); + + return; + } + + setIsVisible(true); + return; + case 'hide': + setIsVisible(false); + return; + case 'toggle': + if(parts.length > 2) + { + toggleCatalogByType(getCatalogTypeFromLink(parts[2])); + + return; + } + + setIsVisible(prevValue => !prevValue); + return; + case 'open': + if(parts.length > 2) + { + if(parts.length === 4) + { + switch(parts[2]) + { + case 'offerId': + openPageByOfferId(parseInt(parts[3])); + return; + } + } + else + { + openPageByName(parts[2]); + } + } + else + { + setIsVisible(true); + } + + return; + } + }, + eventUrlPrefix: 'catalog/' + }; + + AddLinkEventTracker(linkTracker); + + return () => RemoveLinkEventTracker(linkTracker); + }, [ setIsVisible, openPageByOfferId, openPageByName, openCatalogByType, toggleCatalogByType ]); + + return ( + <> + { isVisible && + + setIsVisible(false) } style={ buildersClubHeaderStyle } /> + + { /* Admin banner */ } + { adminMode && +
+ ⚙ Admin Mode + +
} + + +
+ { /* === LEFT SIDEBAR === */ } +
+ + { /* Favorites toggle */ } +
setShowFavorites(!showFavorites) } + > +
+ 0 ? 'text-danger' : 'text-muted' }` } /> + { totalFavs > 0 && + + { totalFavs } + } +
+ { 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 CatalogModernView: FC<{}> = () => +{ + return ( + + + + ); +}; diff --git a/src/components/catalog-modern/views/CatalogPurchaseConfirmView.tsx b/src/components/catalog-modern/views/CatalogPurchaseConfirmView.tsx new file mode 100644 index 0000000..84ce086 --- /dev/null +++ b/src/components/catalog-modern/views/CatalogPurchaseConfirmView.tsx @@ -0,0 +1,10 @@ +import { FC } from 'react'; + +export const CatalogPurchaseConfirmView: FC<{}> = props => +{ + const {} = props; + + return ( +
+ ); +}; diff --git a/src/components/catalog-modern/views/admin/CatalogAdminOfferEditView.tsx b/src/components/catalog-modern/views/admin/CatalogAdminOfferEditView.tsx new file mode 100644 index 0000000..c61c14b --- /dev/null +++ b/src/components/catalog-modern/views/admin/CatalogAdminOfferEditView.tsx @@ -0,0 +1,226 @@ +import { FC, useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { FaSave, FaSpinner, FaTimes, FaTrash } from 'react-icons/fa'; +import { LocalizeText } from '../../../../api'; +import { useCatalogData } from '../../../../hooks'; +import { IOfferEditData, useCatalogAdmin } from '../../CatalogAdminContext'; + +export const CatalogAdminOfferEditView: FC<{}> = () => +{ + const { currentPage = null } = useCatalogData(); + const catalogAdmin = useCatalogAdmin(); + const editingOffer = catalogAdmin?.editingOffer ?? null; + const setEditingOffer = catalogAdmin?.setEditingOffer; + const saveOffer = catalogAdmin?.saveOffer; + const deleteOffer = catalogAdmin?.deleteOffer; + const createOffer = catalogAdmin?.createOffer; + const loading = catalogAdmin?.loading ?? false; + + const [ itemIds, setItemIds ] = useState(''); + const [ catalogName, setCatalogName ] = useState(''); + const [ costCredits, setCostCredits ] = useState(0); + const [ costPoints, setCostPoints ] = useState(0); + const [ pointsType, setPointsType ] = useState(0); + const [ amount, setAmount ] = useState(1); + const [ clubOnly, setClubOnly ] = useState('0'); + const [ extradata, setExtradata ] = useState(''); + const [ haveOffer, setHaveOffer ] = useState('1'); + const [ offerId, setOfferIdGroup ] = useState(-1); + const [ limitedStack, setLimitedStack ] = useState(0); + const [ orderNumber, setOrderNumber ] = useState(0); + const [ isNew, setIsNew ] = useState(false); + + useEffect(() => + { + if(!editingOffer) return; + + if(editingOffer.offerId === -1) + { + setIsNew(true); + setItemIds(''); + setCatalogName(''); + setCostCredits(0); + setCostPoints(0); + setPointsType(0); + setAmount(1); + setClubOnly('0'); + setExtradata(''); + setHaveOffer('1'); + setOfferIdGroup(-1); + setLimitedStack(0); + setOrderNumber(0); + } + else + { + setIsNew(false); + setItemIds(editingOffer.itemIds || ''); + setCatalogName(editingOffer.localizationName || ''); + setCostCredits(editingOffer.priceInCredits); + setCostPoints(editingOffer.priceInActivityPoints); + setPointsType(editingOffer.activityPointType); + setAmount(editingOffer.product?.productCount || 1); + setClubOnly(editingOffer.clubLevel > 0 ? '1' : '0'); + setExtradata(editingOffer.product?.extraParam || ''); + setHaveOffer(editingOffer.haveOffer ? '1' : '0'); + setOfferIdGroup(editingOffer.offerId || -1); + setLimitedStack(0); + setOrderNumber(0); + } + }, [ editingOffer ]); + + if(!editingOffer) return null; + + const handleSave = async () => + { + if(!saveOffer || !createOffer) return; + + const data: IOfferEditData = { + offerId: isNew ? undefined : editingOffer.offerId, + pageId: currentPage?.pageId || 0, + itemIds, + catalogName, + costCredits, + costPoints, + pointsType, + amount, + clubOnly, + extradata, + haveOffer, + offerId_group: offerId, + limitedStack, + orderNumber + }; + + if(isNew) createOffer(data); + else saveOffer(data); + + if(setEditingOffer) setEditingOffer(null); + }; + + const handleDelete = () => + { + if(isNew || !deleteOffer || !confirm(LocalizeText('catalog.admin.delete.offer.confirm'))) return; + + deleteOffer(editingOffer.offerId); + if(setEditingOffer) setEditingOffer(null); + }; + + const inputClass = 'text-[11px] border-2 border-card-grid-item-border rounded px-2 py-1 bg-white placeholder:text-[#4b5563] focus:outline-none focus:border-primary transition-colors'; + + return createPortal( +
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-modern/views/admin/CatalogAdminPageEditView.tsx b/src/components/catalog-modern/views/admin/CatalogAdminPageEditView.tsx new file mode 100644 index 0000000..94b2326 --- /dev/null +++ b/src/components/catalog-modern/views/admin/CatalogAdminPageEditView.tsx @@ -0,0 +1,292 @@ +import { FC, useEffect, useState } from 'react'; +import { FaLanguage, FaSave, FaSpinner, FaTimes, FaTrash } from 'react-icons/fa'; +import { CatalogType, LocalizeText } from '../../../../api'; +import { useCatalogData, useCatalogUiState, useTranslationActions, useTranslationState } from '../../../../hooks'; +import { IPageEditData, useCatalogAdmin } from '../../CatalogAdminContext'; + +const LAYOUT_OPTIONS = [ + 'default_3x3', 'frontpage4', 'pets', 'pets2', 'pets3', + 'spaces_new', 'soundmachine', 'trophies', 'roomads', + 'guild_frontpage', 'guild_forum', 'guild_custom_furni', + 'vip_buy', 'builders_club_frontpage', 'builders_club_addons', 'builders_club_loyalty', 'marketplace', 'marketplace_own_items', + 'recycler', 'recycler_info', 'recycler_prizes', + 'info_loyalty', 'badge_display', 'bots', 'single_bundle', + 'color_grouping', 'recent_purchases', 'custom_prefix' +]; + +const MODE_OPTIONS = [ + { value: 'NORMAL', label: 'Normal' }, + { value: 'BUILDER', label: 'Builder' }, + { value: 'BOTH', label: 'Both' } +]; + +export const CatalogAdminPageEditView: FC<{}> = () => +{ + const { currentPage = null, rootNode = null } = useCatalogData(); + const { activeNodes = [], currentType = CatalogType.NORMAL } = useCatalogUiState(); + const catalogAdmin = useCatalogAdmin(); + const editingPageData = catalogAdmin?.editingPageData ?? false; + const editingRootPage = catalogAdmin?.editingRootPage ?? false; + const editingPageNode = catalogAdmin?.editingPageNode ?? null; + const loading = catalogAdmin?.loading ?? false; + + const [ caption, setCaption ] = useState(''); + const [ captionSave, setCaptionSave ] = useState(''); + const [ catalogMode, setCatalogMode ] = useState('NORMAL'); + const [ pageLayout, setPageLayout ] = useState('default_3x3'); + const [ iconImage, setIconImage ] = useState(0); + const [ minRank, setMinRank ] = useState(1); + const [ visible, setVisible ] = useState('1'); + const [ enabled, setEnabled ] = useState('1'); + const [ orderNum, setOrderNum ] = useState(0); + const [ parentId, setParentId ] = useState(-1); + const [ pageText1, setPageText1 ] = useState(''); + const [ showTranslate, setShowTranslate ] = useState(false); + const [ translateTargetLanguage, setTranslateTargetLanguage ] = useState('en'); + const [ isTranslating, setIsTranslating ] = useState(false); + const [ translateError, setTranslateError ] = useState(null); + const { supportedLanguages = [], languagesLoading = false } = useTranslationState(); + const { translateText, ensureSupportedLanguagesLoaded } = useTranslationActions(); + const targetNode = editingPageNode + ? editingPageNode + : editingRootPage + ? rootNode + : (activeNodes.length > 0 ? activeNodes[activeNodes.length - 1] : null); + + const targetPageId = targetNode?.pageId ?? currentPage?.pageId; + const isRoot = editingRootPage; + + const closeForm = () => + { + catalogAdmin?.setEditingPageData(false); + catalogAdmin?.setEditingRootPage(false); + catalogAdmin?.setEditingPageNode(null); + }; + + useEffect(() => + { + if(!editingPageData || !targetNode) return; + + // The server appends " (pageId)" to the caption for mods/admins (see + // CatalogPagesListComposer). Strip that exact suffix before seeding the + // edit field, otherwise saving folds the id back into the stored + // caption and it multiplies on every edit ("Wired (1114) (1114) ..."). + const rawCaption = (targetNode.localization || '').replace(new RegExp(`\\s*\\(${ targetNode.pageId }\\)\\s*$`), ''); + + setCaption(rawCaption); + setCaptionSave(targetNode.pageName || rawCaption); + setCatalogMode(currentType === CatalogType.BUILDER ? 'BUILDER' : 'NORMAL'); + setPageLayout(currentPage?.layoutCode || 'default_3x3'); + setIconImage(targetNode.iconId ?? 0); + setVisible(targetNode.isVisible ? '1' : '0'); + setEnabled('1'); + setMinRank(1); + setOrderNum(0); + const matchesLoadedPage = currentPage && targetPageId === currentPage.pageId; + const existingText1 = matchesLoadedPage && currentPage.localization + ? currentPage.localization.getText(0) + : ''; + setPageText1(existingText1 || ''); + setShowTranslate(false); + setIsTranslating(false); + setTranslateError(null); + const wireParentId = targetNode.parentId; + setParentId(typeof wireParentId === 'number' && wireParentId !== -1 + ? wireParentId + : (targetNode.parent ? targetNode.parent.pageId : -1)); + }, [ editingPageData, targetNode, currentPage, currentType ]); + + if(!editingPageData || !targetNode) return null; + + const inputClass = 'text-[11px] border-2 border-card-grid-item-border rounded px-2 py-1 bg-white focus:outline-none focus:border-primary transition-colors'; + + const handleSave = async () => + { + if(!catalogAdmin?.savePage) return; + + const data: IPageEditData = { + pageId: targetPageId, + caption, + captionSave, + catalogMode, + pageLayout, + iconImage, + minRank, + visible, + enabled, + orderNum, + parentId, + pageText1, + }; + + catalogAdmin.savePage(data); + + closeForm(); + }; + + const openTranslate = () => + { + const next = !showTranslate; + setShowTranslate(next); + setTranslateError(null); + if(next) ensureSupportedLanguagesLoaded(); + }; + + const runTranslate = async () => + { + if(!pageText1.trim().length) + { + setTranslateError('Nothing to translate yet.'); + return; + } + + if(!translateTargetLanguage) + { + setTranslateError('Pick a language first.'); + return; + } + + setIsTranslating(true); + setTranslateError(null); + + try + { + const result = await translateText(pageText1, translateTargetLanguage); + setPageText1(result?.translatedText || pageText1); + setShowTranslate(false); + } + catch(error) + { + setTranslateError((error as Error)?.message || 'Translation failed.'); + } + finally + { + setIsTranslating(false); + } + }; + + const handleDelete = async () => + { + if(!catalogAdmin?.deletePage || isRoot) return; + if(!confirm(LocalizeText('catalog.admin.delete.page.confirm', [ 'name' ], [ targetNode.localization ]))) return; + + catalogAdmin.deletePage(targetPageId); + + closeForm(); + }; + + return ( +
+
+ + { isRoot ? LocalizeText('catalog.admin.edit.root') : `${ LocalizeText('catalog.admin.edit') } ${ targetNode.localization }` } + + +
+ +
+
+ + setCaption(e.target.value) } /> +
+
+ + setMinRank(parseInt(e.target.value) || 1) } /> +
+
+ + setCaptionSave(e.target.value) } /> +
+
+ + setIconImage(parseInt(e.target.value) || 0) } /> +
+
+ + +
+
+ + +
+
+ + setOrderNum(parseInt(e.target.value) || 0) } /> +
+
+ + setParentId(parseInt(e.target.value) || -1) } /> +
+
+ + +
+
+
+ + +
+ { showTranslate && +
+ + + +
} + { translateError && { translateError } } +