From 33c31fe07de4795c3ae7d0ab3bb3c65640b07da1 Mon Sep 17 00:00:00 2001 From: duckietm Date: Mon, 23 Mar 2026 15:02:20 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=86=95=20=20Added=20New=20catalogue=20pag?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/renderer-config.json | 110 ------- src/api/ui-settings/IUiSettings.ts | 43 +++ src/api/ui-settings/UiSettingsContext.tsx | 224 +++++++++++++ src/api/ui-settings/index.ts | 2 + .../catalog/CatalogAdminContext.tsx | 296 ++++++++++++++++++ src/components/catalog/CatalogClassicView.tsx | 183 +++++++++++ src/components/catalog/CatalogModernView.tsx | 291 +++++++++++++++++ src/components/catalog/CatalogView.tsx | 114 +------ .../views/admin/CatalogAdminOfferEditView.tsx | 225 +++++++++++++ .../views/admin/CatalogAdminPageEditView.tsx | 153 +++++++++ .../catalog-rail/CatalogRailItemView.tsx | 30 ++ .../views/favorites/CatalogFavoritesView.tsx | 154 +++++++++ .../navigation/CatalogBreadcrumbView.tsx | 39 +++ .../navigation/CatalogNavigationItemView.tsx | 132 +++++++- .../navigation/CatalogNavigationView.tsx | 23 +- .../page/common/CatalogGridOfferView.tsx | 26 +- .../views/page/common/CatalogSearchView.tsx | 43 +-- .../layout/CatalogLayoutCustomPrefixView.tsx | 85 ++--- .../page/layout/CatalogLayoutDefaultView.tsx | 125 +++++--- .../page/layout/CatalogLayoutPets3View.tsx | 33 +- .../page/layout/CatalogLayoutTrophiesView.tsx | 128 ++++++-- .../page/layout/pets/CatalogLayoutPetView.tsx | 177 ++++++++--- .../widgets/CatalogItemGridWidgetView.tsx | 66 +++- .../widgets/CatalogPriceDisplayWidgetView.tsx | 14 +- .../page/widgets/CatalogSpinnerWidgetView.tsx | 34 +- .../InterfaceColorTabView.tsx | 288 +++++++++++++++++ .../InterfaceImageTabView.tsx | 52 +++ src/hooks/catalog/index.ts | 1 + src/hooks/catalog/useCatalogFavorites.ts | 129 ++++++++ 29 files changed, 2746 insertions(+), 474 deletions(-) delete mode 100644 public/renderer-config.json create mode 100644 src/api/ui-settings/IUiSettings.ts create mode 100644 src/api/ui-settings/UiSettingsContext.tsx create mode 100644 src/api/ui-settings/index.ts create mode 100644 src/components/catalog/CatalogAdminContext.tsx create mode 100644 src/components/catalog/CatalogClassicView.tsx create mode 100644 src/components/catalog/CatalogModernView.tsx create mode 100644 src/components/catalog/views/admin/CatalogAdminOfferEditView.tsx create mode 100644 src/components/catalog/views/admin/CatalogAdminPageEditView.tsx create mode 100644 src/components/catalog/views/catalog-rail/CatalogRailItemView.tsx create mode 100644 src/components/catalog/views/favorites/CatalogFavoritesView.tsx create mode 100644 src/components/catalog/views/navigation/CatalogBreadcrumbView.tsx create mode 100644 src/components/interface-settings/InterfaceColorTabView.tsx create mode 100644 src/components/interface-settings/InterfaceImageTabView.tsx create mode 100644 src/hooks/catalog/useCatalogFavorites.ts diff --git a/public/renderer-config.json b/public/renderer-config.json deleted file mode 100644 index 1c2b22a..0000000 --- a/public/renderer-config.json +++ /dev/null @@ -1,110 +0,0 @@ -{ - "socket.url": "ws://localhost:2097", - "asset.url": "http://localhost:3000/nitro-assets", - "image.library.url": "http://localhost:3000/swf/c_images/", - "hof.furni.url": "http://localhost:3000/swf/dcr/hof_furni", - "images.url": "${asset.url}/images", - "gamedata.url": "${asset.url}/gamedata", - "sounds.url": "${asset.url}/sounds/%sample%.mp3", - "external.texts.url": [ "${gamedata.url}/ExternalTexts.json", "${gamedata.url}/UITexts.json" ], - "external.samples.url": "${hof.furni.url}/mp3/sound_machine_sample_%sample%.mp3", - "furnidata.url": "${gamedata.url}/FurnitureData.json", - "productdata.url": "${gamedata.url}/ProductData.json", - "avatar.actions.url": "${gamedata.url}/HabboAvatarActions.json", - "avatar.figuredata.url": "${gamedata.url}/FigureData.json", - "avatar.figuremap.url": "${gamedata.url}/FigureMap.json", - "avatar.effectmap.url": "${gamedata.url}/EffectMap.json", - "avatar.asset.url": "${asset.url}/bundled/figure/%libname%.nitro", - "avatar.asset.effect.url": "${asset.url}/bundled/effect/%libname%.nitro", - "furni.asset.url": "${asset.url}/bundled/furniture/%libname%.nitro", - "furni.asset.icon.url": "${hof.furni.url}/icons/%libname%%param%_icon.png", - "pet.asset.url": "${asset.url}/bundled/pet/%libname%.nitro", - "generic.asset.url": "${asset.url}/bundled/generic/%libname%.nitro", - "badge.asset.url": "${image.library.url}album1584/%badgename%.gif", - "furni.rotation.bounce.steps": 20, - "furni.rotation.bounce.height": 0.0625, - "enable.avatar.arrow": false, - "system.log.debug": true, - "system.log.warn": true, - "system.log.error": true, - "system.log.events": true, - "system.log.packets": true, - "system.fps.animation": 24, - "system.fps.max": 60, - "system.pong.manually": true, - "system.pong.interval.ms": 20000, - "room.color.skip.transition": true, - "room.landscapes.enabled": true, - "avatar.mandatory.libraries": [ - "bd:1", - "li:0" - ], - "avatar.mandatory.effect.libraries": [ - "dance.1", - "dance.2", - "dance.3", - "dance.4" - ], - "avatar.default.figuredata": {"palettes":[{"id":1,"colors":[{"id":99999,"index":1001,"club":0,"selectable":false,"hexCode":"DDDDDD"},{"id":99998,"index":1001,"club":0,"selectable":false,"hexCode":"FAFAFA"}]},{"id":3,"colors":[{"id":10001,"index":1001,"club":0,"selectable":false,"hexCode":"EEEEEE"},{"id":10002,"index":1002,"club":0,"selectable":false,"hexCode":"FA3831"},{"id":10003,"index":1003,"club":0,"selectable":false,"hexCode":"FD92A0"},{"id":10004,"index":1004,"club":0,"selectable":false,"hexCode":"2AC7D2"},{"id":10005,"index":1005,"club":0,"selectable":false,"hexCode":"35332C"},{"id":10006,"index":1006,"club":0,"selectable":false,"hexCode":"EFFF92"},{"id":10007,"index":1007,"club":0,"selectable":false,"hexCode":"C6FF98"},{"id":10008,"index":1008,"club":0,"selectable":false,"hexCode":"FF925A"},{"id":10009,"index":1009,"club":0,"selectable":false,"hexCode":"9D597E"},{"id":10010,"index":1010,"club":0,"selectable":false,"hexCode":"B6F3FF"},{"id":10011,"index":1011,"club":0,"selectable":false,"hexCode":"6DFF33"},{"id":10012,"index":1012,"club":0,"selectable":false,"hexCode":"3378C9"},{"id":10013,"index":1013,"club":0,"selectable":false,"hexCode":"FFB631"},{"id":10014,"index":1014,"club":0,"selectable":false,"hexCode":"DFA1E9"},{"id":10015,"index":1015,"club":0,"selectable":false,"hexCode":"F9FB32"},{"id":10016,"index":1016,"club":0,"selectable":false,"hexCode":"CAAF8F"},{"id":10017,"index":1017,"club":0,"selectable":false,"hexCode":"C5C6C5"},{"id":10018,"index":1018,"club":0,"selectable":false,"hexCode":"47623D"},{"id":10019,"index":1019,"club":0,"selectable":false,"hexCode":"8A8361"},{"id":10020,"index":1020,"club":0,"selectable":false,"hexCode":"FF8C33"},{"id":10021,"index":1021,"club":0,"selectable":false,"hexCode":"54C627"},{"id":10022,"index":1022,"club":0,"selectable":false,"hexCode":"1E6C99"},{"id":10023,"index":1023,"club":0,"selectable":false,"hexCode":"984F88"},{"id":10024,"index":1024,"club":0,"selectable":false,"hexCode":"77C8FF"},{"id":10025,"index":1025,"club":0,"selectable":false,"hexCode":"FFC08E"},{"id":10026,"index":1026,"club":0,"selectable":false,"hexCode":"3C4B87"},{"id":10027,"index":1027,"club":0,"selectable":false,"hexCode":"7C2C47"},{"id":10028,"index":1028,"club":0,"selectable":false,"hexCode":"D7FFE3"},{"id":10029,"index":1029,"club":0,"selectable":false,"hexCode":"8F3F1C"},{"id":10030,"index":1030,"club":0,"selectable":false,"hexCode":"FF6393"},{"id":10031,"index":1031,"club":0,"selectable":false,"hexCode":"1F9B79"},{"id":10032,"index":1032,"club":0,"selectable":false,"hexCode":"FDFF33"}]}],"setTypes":[{"type":"hd","paletteId":1,"mandatory_f_0":true,"mandatory_f_1":true,"mandatory_m_0":true,"mandatory_m_1":true,"sets":[{"id":99999,"gender":"U","club":0,"colorable":true,"selectable":false,"preselectable":false,"sellable":false,"parts":[{"id":1,"type":"bd","colorable":true,"index":0,"colorindex":1},{"id":1,"type":"hd","colorable":true,"index":0,"colorindex":1},{"id":1,"type":"lh","colorable":true,"index":0,"colorindex":1},{"id":1,"type":"rh","colorable":true,"index":0,"colorindex":1}]}]},{"type":"bds","paletteId":1,"mandatory_f_0":false,"mandatory_f_1":false,"mandatory_m_0":false,"mandatory_m_1":false,"sets":[{"id":10001,"gender":"U","club":0,"colorable":true,"selectable":false,"preselectable":false,"sellable":false,"parts":[{"id":10001,"type":"bds","colorable":true,"index":0,"colorindex":1},{"id":10001,"type":"lhs","colorable":true,"index":0,"colorindex":1},{"id":10001,"type":"rhs","colorable":true,"index":0,"colorindex":1}],"hiddenLayers":[{"partType":"bd"},{"partType":"rh"},{"partType":"lh"}]}]},{"type":"ss","paletteId":3,"mandatory_f_0":false,"mandatory_f_1":false,"mandatory_m_0":false,"mandatory_m_1":false,"sets":[{"id":10010,"gender":"F","club":0,"colorable":true,"selectable":false,"preselectable":false,"sellable":false,"parts":[{"id":10001,"type":"ss","colorable":true,"index":0,"colorindex":1}],"hiddenLayers":[{"partType":"ch"},{"partType":"lg"},{"partType":"ca"},{"partType":"wa"},{"partType":"sh"},{"partType":"ls"},{"partType":"rs"},{"partType":"lc"},{"partType":"rc"},{"partType":"cc"},{"partType":"cp"}]},{"id":10011,"gender":"M","club":0,"colorable":true,"selectable":false,"preselectable":false,"sellable":false,"parts":[{"id":10002,"type":"ss","colorable":true,"index":0,"colorindex":1}],"hiddenLayers":[{"partType":"ch"},{"partType":"lg"},{"partType":"ca"},{"partType":"wa"},{"partType":"sh"},{"partType":"ls"},{"partType":"rs"},{"partType":"lc"},{"partType":"rc"},{"partType":"cc"},{"partType":"cp"}]}]}]}, - "avatar.default.actions": { - "actions": [ - { - "id": "Default", - "state": "std", - "precedence": 1000, - "main": true, - "isDefault": true, - "geometryType": "vertical", - "activePartSet": "figure", - "assetPartDefinition": "std" - } - ] - }, - "pet.types": [ - "dog", - "cat", - "croco", - "terrier", - "bear", - "pig", - "lion", - "rhino", - "spider", - "turtle", - "chicken", - "frog", - "dragon", - "monster", - "monkey", - "horse", - "monsterplant", - "bunnyeaster", - "bunnyevil", - "bunnydepressed", - "bunnylove", - "pigeongood", - "pigeonevil", - "demonmonkey", - "bearbaby", - "terrierbaby", - "gnome", - "leprechaun", - "kittenbaby", - "puppybaby", - "pigletbaby", - "haloompa", - "fools", - "pterosaur", - "velociraptor", - "cow", - "dragondog" - ], - "preload.assets.urls": [ - "${asset.url}/bundled/generic/avatar_additions.nitro", - "${asset.url}/bundled/generic/group_badge.nitro", - "${asset.url}/bundled/generic/floor_editor.nitro", - "${images.url}/loading_icon.png", - "${images.url}/clear_icon.png", - "${images.url}/big_arrow.png" - ] -} diff --git a/src/api/ui-settings/IUiSettings.ts b/src/api/ui-settings/IUiSettings.ts new file mode 100644 index 0000000..5c6e96d --- /dev/null +++ b/src/api/ui-settings/IUiSettings.ts @@ -0,0 +1,43 @@ +export interface IUiSettings +{ + colorMode: 'color' | 'image' | 'default'; + headerColor: string; + headerImageUrl: string; + headerAlpha: number; +} + +export const DEFAULT_UI_SETTINGS: IUiSettings = { + colorMode: 'default', + headerColor: '#1E7295', + headerImageUrl: '', + headerAlpha: 100 +}; + +export const PRESET_COLORS: string[] = [ + '#000000', '#444444', '#888888', '#CCCCCC', '#660000', '#CC3333', '#FF6666', '#CC6600', + '#FF3333', '#FF6633', '#FF9933', '#FFCC00', '#FFFF00', '#66FF00', '#00CC00', '#009900', + '#00FFCC', '#33CCFF', '#3366FF', '#0000CC', '#6633CC', '#9933FF', '#CC33FF', '#FF66CC', + '#FF99CC', '#1E7295', '#185D79', '#2DABC2', '#2B91A7', '#283F5D' +]; + +export interface IThemePreset +{ + name: string; + color: string; + alpha: number; +} + +export const THEME_PRESETS: IThemePreset[] = [ + { name: 'default', color: '#1E7295', alpha: 100 }, + { name: 'ocean', color: '#0077B6', alpha: 100 }, + { name: 'forest', color: '#2D6A4F', alpha: 100 }, + { name: 'sunset', color: '#E76F51', alpha: 100 }, + { name: 'royal', color: '#7B2CBF', alpha: 100 }, + { name: 'midnight', color: '#1B1B2F', alpha: 100 }, + { name: 'cherry', color: '#C1121F', alpha: 100 }, + { name: 'gold', color: '#B8860B', alpha: 100 }, + { name: 'slate', color: '#475569', alpha: 100 }, + { name: 'candy', color: '#FF69B4', alpha: 100 }, + { name: 'emerald', color: '#059669', alpha: 100 }, + { name: 'volcano', color: '#DC2626', alpha: 90 }, +]; diff --git a/src/api/ui-settings/UiSettingsContext.tsx b/src/api/ui-settings/UiSettingsContext.tsx new file mode 100644 index 0000000..99685ce --- /dev/null +++ b/src/api/ui-settings/UiSettingsContext.tsx @@ -0,0 +1,224 @@ +import { GetCommunication, UiSettingsDataEvent, UiSettingsLoadComposer, UiSettingsSaveComposer } from '@nitrots/nitro-renderer'; +import { createContext, FC, PropsWithChildren, useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { DEFAULT_UI_SETTINGS, IUiSettings } from './IUiSettings'; + +const STORAGE_KEY = 'nitro.ui.settings'; + +interface IUiSettingsContext +{ + settings: IUiSettings; + isCustomActive: boolean; + updateSettings: (partial: Partial) => void; + resetSettings: () => void; + getHeaderStyle: () => React.CSSProperties; + getTabsStyle: () => React.CSSProperties; + getAccentColor: () => string; +} + +const UiSettingsContext = createContext({ + settings: DEFAULT_UI_SETTINGS, + isCustomActive: false, + updateSettings: () => {}, + resetSettings: () => {}, + getHeaderStyle: () => ({}), + getTabsStyle: () => ({}), + getAccentColor: () => DEFAULT_UI_SETTINGS.headerColor +}); + +const darkenColor = (hex: string, amount: number): string => +{ + const num = parseInt(hex.replace('#', ''), 16); + const r = Math.max(0, ((num >> 16) & 0xFF) - amount); + const g = Math.max(0, ((num >> 8) & 0xFF) - amount); + const b = Math.max(0, (num & 0xFF) - amount); + + return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1); +}; + +const loadSettings = (): IUiSettings => +{ + try + { + const stored = localStorage.getItem(STORAGE_KEY); + if(stored) return { ...DEFAULT_UI_SETTINGS, ...JSON.parse(stored) }; + } + catch(e) {} + + return { ...DEFAULT_UI_SETTINGS }; +}; + +const saveSettings = (settings: IUiSettings): void => +{ + try + { + localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); + } + catch(e) {} +}; + +const sendComposer = (composer: any): void => +{ + try + { + GetCommunication()?.connection?.send(composer); + } + catch(e) {} +}; + +export const UiSettingsProvider: FC = ({ children }) => +{ + const [ settings, setSettings ] = useState(loadSettings); + const serverSaveTimerRef = useRef>(null); + + // Carica dal server al mount e ascolta risposta + useEffect(() => + { + sendComposer(new UiSettingsLoadComposer()); + + const connection = GetCommunication()?.connection; + + if(!connection) return; + + const handler = (event: any) => + { + try + { + const parser = event.getParser(); + const json = parser?.settingsJson; + + if(json && json !== '{}') + { + const serverSettings = { ...DEFAULT_UI_SETTINGS, ...JSON.parse(json) }; + setSettings(serverSettings); + saveSettings(serverSettings); + } + } + catch(e) {} + }; + + connection.addMessageEvent(new UiSettingsDataEvent(handler)); + }, []); + + const syncToServer = useCallback((settingsToSave: IUiSettings) => + { + if(serverSaveTimerRef.current) clearTimeout(serverSaveTimerRef.current); + + serverSaveTimerRef.current = setTimeout(() => + { + sendComposer(new UiSettingsSaveComposer(JSON.stringify(settingsToSave))); + }, 1000); + }, []); + + const updateSettings = useCallback((partial: Partial) => + { + setSettings(prev => + { + const updated = { ...prev, ...partial }; + saveSettings(updated); + syncToServer(updated); + + return updated; + }); + }, [ syncToServer ]); + + const resetSettings = useCallback(() => + { + setSettings({ ...DEFAULT_UI_SETTINGS }); + saveSettings(DEFAULT_UI_SETTINGS); + syncToServer(DEFAULT_UI_SETTINGS); + }, [ syncToServer ]); + + const getHeaderStyle = useCallback((): React.CSSProperties => + { + if(settings.colorMode === 'color') + { + const alpha = (settings.headerAlpha ?? 100) / 100; + const num = parseInt(settings.headerColor.replace('#', ''), 16); + const r = (num >> 16) & 0xFF; + const g = (num >> 8) & 0xFF; + const b = num & 0xFF; + + return { backgroundColor: `rgba(${ r }, ${ g }, ${ b }, ${ alpha })` }; + } + + if(settings.colorMode === 'image' && settings.headerImageUrl) + { + return { + backgroundImage: `url(${ settings.headerImageUrl })`, + backgroundSize: 'cover', + backgroundPosition: 'center', + backgroundRepeat: 'repeat' + }; + } + + return {}; + }, [ settings ]); + + const getTabsStyle = useCallback((): React.CSSProperties => + { + if(settings.colorMode === 'color') + { + return { backgroundColor: darkenColor(settings.headerColor, 30) }; + } + + if(settings.colorMode === 'image' && settings.headerImageUrl) + { + return { + backgroundImage: `url(${ settings.headerImageUrl })`, + backgroundSize: 'cover', + backgroundPosition: 'center bottom', + backgroundRepeat: 'repeat' + }; + } + + return {}; + }, [ settings ]); + + const getAccentColor = useCallback((): string => + { + if(settings.colorMode === 'color') return settings.headerColor; + + return DEFAULT_UI_SETTINGS.headerColor; + }, [ settings ]); + + const isCustomActive = settings.colorMode !== 'default'; + + const ALL_CSS_VARS = [ + '--ui-accent-color', '--ui-accent-dark', + '--ui-ctx-bg', '--ui-ctx-header-bg', '--ui-ctx-item-bg1', '--ui-ctx-item-bg2', + '--ui-btn-primary-bg', '--ui-btn-primary-border', + '--ui-dark-bg', '--ui-dark-border' + ]; + + useEffect(() => + { + const root = document.documentElement; + + if(settings.colorMode === 'color') + { + const c = settings.headerColor; + root.style.setProperty('--ui-accent-color', c); + root.style.setProperty('--ui-accent-dark', darkenColor(c, 30)); + root.style.setProperty('--ui-ctx-bg', darkenColor(c, 50)); + root.style.setProperty('--ui-ctx-header-bg', darkenColor(c, 20)); + root.style.setProperty('--ui-ctx-item-bg1', darkenColor(c, 60)); + root.style.setProperty('--ui-ctx-item-bg2', darkenColor(c, 70)); + root.style.setProperty('--ui-btn-primary-bg', c); + root.style.setProperty('--ui-btn-primary-border', darkenColor(c, 20)); + root.style.setProperty('--ui-dark-bg', darkenColor(c, 55)); + root.style.setProperty('--ui-dark-border', darkenColor(c, 60)); + } + else + { + ALL_CSS_VARS.forEach(v => root.style.removeProperty(v)); + } + }, [ settings ]); + + return ( + + { children } + + ); +}; + +export const useUiSettings = () => useContext(UiSettingsContext); diff --git a/src/api/ui-settings/index.ts b/src/api/ui-settings/index.ts new file mode 100644 index 0000000..255a5da --- /dev/null +++ b/src/api/ui-settings/index.ts @@ -0,0 +1,2 @@ +export * from './IUiSettings'; +export * from './UiSettingsContext'; diff --git a/src/components/catalog/CatalogAdminContext.tsx b/src/components/catalog/CatalogAdminContext.tsx new file mode 100644 index 0000000..5a5fe18 --- /dev/null +++ b/src/components/catalog/CatalogAdminContext.tsx @@ -0,0 +1,296 @@ +import { CatalogAdminCreateOfferComposer, CatalogAdminCreatePageComposer, CatalogAdminDeleteOfferComposer, CatalogAdminDeletePageComposer, CatalogAdminMoveOfferComposer, CatalogAdminMovePageComposer, CatalogAdminPublishComposer, CatalogAdminResultEvent, CatalogAdminSaveOfferComposer, CatalogAdminSavePageComposer } from '@nitrots/nitro-renderer'; +import { createContext, FC, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { ICatalogNode, IPurchasableOffer, NotificationAlertType, SendMessageComposer } from '../../api'; +import { useMessageEvent, useNotification } from '../../hooks'; + +export interface IPageEditData +{ + pageId?: number; + caption: string; + parentId: number; + pageLayout: string; + enabled: string; + visible: string; + minRank: number; + clubOnly?: string; + orderNum: number; + pageHeadline?: string; + pageTeaser?: string; + pageSpecial?: string; + pageText1?: string; + pageText2?: string; + pageTextDetails?: string; + pageTextTeaser?: string; +} + +export interface IOfferEditData +{ + offerId?: number; + pageId: number; + itemIds: string; + catalogName: string; + costCredits: number; + costPoints: number; + pointsType: number; + amount: number; + clubOnly: string; + extradata: string; + haveOffer: string; + offerId_group: number; + limitedStack: number; + orderNumber: number; +} + +interface ICatalogAdminContext +{ + adminMode: boolean; + setAdminMode: (value: boolean) => void; + editingOffer: IPurchasableOffer | null; + setEditingOffer: (offer: IPurchasableOffer | null) => void; + editingPageData: boolean; + setEditingPageData: (value: boolean) => void; + editingRootPage: boolean; + setEditingRootPage: (value: boolean) => void; + editingPageNode: ICatalogNode | null; + setEditingPageNode: (node: ICatalogNode | null) => void; + loading: boolean; + lastError: string | null; + savePage: (data: IPageEditData) => void; + createPage: (data: IPageEditData) => void; + deletePage: (pageId: number) => void; + saveOffer: (data: IOfferEditData) => void; + createOffer: (data: IOfferEditData) => void; + deleteOffer: (offerId: number) => void; + reorderOffers: (orders: { id: number; orderNumber: number }[]) => void; + reorderPage: (pageId: number, newParentId: number, newIndex: number) => void; + togglePageEnabled: (pageId: number) => void; + togglePageVisible: (pageId: number) => void; + publishCatalog: () => void; + hasPendingChanges: boolean; +} + +const CatalogAdminContext = createContext(null); + +export const useCatalogAdmin = () => useContext(CatalogAdminContext); + +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 [ hasPendingChanges, setHasPendingChanges ] = useState(false); + const pendingActionRef = useRef(null); + const { simpleAlert = null } = useNotification(); + + // Keyboard shortcuts: Esc to close edit panels + useEffect(() => + { + if(!adminMode) return; + + const handleKeyDown = (e: KeyboardEvent) => + { + if(e.key === 'Escape') + { + if(editingOffer) { setEditingOffer(null); e.preventDefault(); return; } + if(editingPageData || editingRootPage || editingPageNode) + { + setEditingPageData(false); + setEditingRootPage(false); + setEditingPageNode(null); + e.preventDefault(); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + + return () => window.removeEventListener('keydown', handleKeyDown); + }, [ adminMode, editingOffer, editingPageData, editingRootPage, editingPageNode ]); + + useMessageEvent(CatalogAdminResultEvent, (event: CatalogAdminResultEvent) => + { + const parser = event.getParser(); + const action = pendingActionRef.current; + + pendingActionRef.current = null; + setLoading(false); + + if(!parser.success) + { + setLastError(parser.message || 'Operation failed'); + + if(simpleAlert) + { + simpleAlert(parser.message || 'Operation failed', NotificationAlertType.ALERT, null, null, 'Admin Error'); + } + } + else + { + setLastError(null); + setEditingOffer(null); + setEditingPageData(false); + setEditingRootPage(false); + setEditingPageNode(null); + + if(action === 'publish') + { + setHasPendingChanges(false); + } + else + { + setHasPendingChanges(true); + } + + if(simpleAlert && action) + { + const messages: Record = { + 'savePage': 'Page saved (publish to apply)', + 'createPage': 'Page created (publish to apply)', + 'deletePage': 'Page deleted (publish to apply)', + 'saveOffer': 'Offer saved (publish to apply)', + 'createOffer': 'Offer created (publish to apply)', + 'deleteOffer': 'Offer deleted (publish to apply)', + 'reorder': 'Order updated (publish to apply)', + 'toggleEnabled': 'Page toggled (publish to apply)', + 'toggleVisible': 'Visibility toggled (publish to apply)', + 'movePage': 'Page moved (publish to apply)', + 'publish': 'Catalog published! All users updated.', + }; + + simpleAlert(messages[action] || 'Operation completed', NotificationAlertType.DEFAULT, null, null, 'Catalog Admin'); + } + } + }); + + const savePage = useCallback((data: IPageEditData) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'savePage'; + SendMessageComposer(new CatalogAdminSavePageComposer( + data.pageId || 0, data.caption, data.caption, data.pageLayout, 0, + data.minRank, data.visible === '1', data.enabled === '1', + data.orderNum, data.parentId, + data.pageHeadline || '', data.pageTeaser || '', data.pageTextDetails || '' + )); + }, []); + + const createPage = useCallback((data: IPageEditData) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'createPage'; + SendMessageComposer(new CatalogAdminCreatePageComposer( + data.caption, data.caption, data.pageLayout, 0, + data.minRank, data.visible === '1', data.enabled === '1', + data.orderNum, data.parentId + )); + }, []); + + const deletePage = useCallback((pageId: number) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'deletePage'; + SendMessageComposer(new CatalogAdminDeletePageComposer(pageId)); + }, []); + + const saveOffer = useCallback((data: IOfferEditData) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'saveOffer'; + SendMessageComposer(new CatalogAdminSaveOfferComposer( + data.offerId || 0, data.pageId, parseInt(data.itemIds) || 0, + data.catalogName, data.costCredits, data.costPoints, data.pointsType, + data.amount, data.clubOnly === '1' ? 1 : 0, data.extradata, + data.haveOffer === '1', data.offerId_group, data.limitedStack, data.orderNumber + )); + }, []); + + const createOffer = useCallback((data: IOfferEditData) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'createOffer'; + SendMessageComposer(new CatalogAdminCreateOfferComposer( + data.pageId, parseInt(data.itemIds) || 0, + data.catalogName, data.costCredits, data.costPoints, data.pointsType, + data.amount, data.clubOnly === '1' ? 1 : 0, data.extradata, + data.haveOffer === '1', data.offerId_group, data.limitedStack, data.orderNumber + )); + }, []); + + const deleteOffer = useCallback((offerId: number) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'deleteOffer'; + SendMessageComposer(new CatalogAdminDeleteOfferComposer(offerId)); + }, []); + + const reorderOffers = useCallback((orders: { id: number; orderNumber: number }[]) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'reorder'; + + for(const order of orders) + { + SendMessageComposer(new CatalogAdminMoveOfferComposer(order.id, order.orderNumber)); + } + }, []); + + const reorderPage = useCallback((pageId: number, newParentId: number, newIndex: number) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'movePage'; + SendMessageComposer(new CatalogAdminMovePageComposer(pageId, newParentId, newIndex)); + }, []); + + const togglePageEnabled = useCallback((pageId: number) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'toggleEnabled'; + SendMessageComposer(new CatalogAdminMovePageComposer(pageId, -1, -1)); + }, []); + + const togglePageVisible = useCallback((pageId: number) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'toggleVisible'; + SendMessageComposer(new CatalogAdminMovePageComposer(pageId, -2, -1)); + }, []); + + const publishCatalog = useCallback(() => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'publish'; + SendMessageComposer(new CatalogAdminPublishComposer()); + }, []); + + return ( + + { children } + + ); +}; diff --git a/src/components/catalog/CatalogClassicView.tsx b/src/components/catalog/CatalogClassicView.tsx new file mode 100644 index 0000000..a5fabd4 --- /dev/null +++ b/src/components/catalog/CatalogClassicView.tsx @@ -0,0 +1,183 @@ +import { AddLinkEventTracker, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; +import { FC, useEffect } from 'react'; +import { FaCog, FaEdit, FaEye, FaEyeSlash, FaPlus, FaTrash } from 'react-icons/fa'; +import { GetConfigurationValue, LocalizeText } from '../../api'; +import { Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common'; +import { useCatalog } from '../../hooks'; +import { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext'; +import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView'; +import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView'; +import { CatalogIconView } from './views/catalog-icon/CatalogIconView'; +import { CatalogGiftView } from './views/gift/CatalogGiftView'; +import { CatalogNavigationView } from './views/navigation/CatalogNavigationView'; +import { GetCatalogLayout } from './views/page/layout/GetCatalogLayout'; +import { MarketplacePostOfferView } from './views/page/layout/marketplace/MarketplacePostOfferView'; + +const CatalogClassicViewInner: FC<{}> = () => +{ + const { isVisible = false, setIsVisible = null, rootNode = null, currentPage = null, navigationHidden = false, setNavigationHidden = null, activeNodes = [], searchResult = null, setSearchResult = null, openPageByName = null, openPageByOfferId = null, activateNode = null } = useCatalog(); + const catalogAdmin = useCatalogAdmin(); + const adminMode = catalogAdmin?.adminMode ?? false; + const setAdminMode = catalogAdmin?.setAdminMode ?? (() => {}); + const hasPendingChanges = catalogAdmin?.hasPendingChanges ?? false; + const publishCatalog = catalogAdmin?.publishCatalog ?? (() => {}); + const loading = catalogAdmin?.loading ?? false; + + const isMod = GetSessionDataManager().isModerator; + + useEffect(() => + { + const linkTracker: ILinkEventTracker = { + linkReceived: (url: string) => + { + const parts = url.split('/'); + + if(parts.length < 2) return; + + switch(parts[1]) + { + case 'show': + setIsVisible(true); + return; + case 'hide': + setIsVisible(false); + return; + case 'toggle': + setIsVisible(prevValue => !prevValue); + return; + case 'open': + if(parts.length > 2) + { + if(parts.length === 4) + { + switch(parts[2]) + { + case 'offerId': + openPageByOfferId(parseInt(parts[3])); + return; + } + } + else + { + openPageByName(parts[2]); + } + } + else + { + setIsVisible(true); + } + + return; + } + }, + eventUrlPrefix: 'catalog/' + }; + + AddLinkEventTracker(linkTracker); + + return () => RemoveLinkEventTracker(linkTracker); + }, [ setIsVisible, openPageByOfferId, openPageByName ]); + + return ( + <> + { isVisible && + + 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; + + 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 && } + { GetCatalogLayout(currentPage, () => setNavigationHidden(true)) } + + +
+
} + + + + + ); +}; + +export const CatalogClassicView: FC<{}> = () => +{ + return ( + + + + ); +}; diff --git a/src/components/catalog/CatalogModernView.tsx b/src/components/catalog/CatalogModernView.tsx new file mode 100644 index 0000000..958e3ea --- /dev/null +++ b/src/components/catalog/CatalogModernView.tsx @@ -0,0 +1,291 @@ +import { AddLinkEventTracker, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; +import { FC, useEffect, useState } from 'react'; +import { FaCog, FaEdit, FaEye, FaEyeSlash, FaHeart, FaPlus, FaStar, FaTrash } from 'react-icons/fa'; +import { LocalizeText } from '../../api'; +import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common'; +import { useCatalog, useCatalogFavorites } from '../../hooks'; +import { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext'; +import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView'; +import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView'; +import { CatalogIconView } from './views/catalog-icon/CatalogIconView'; +import { CatalogFavoritesView } from './views/favorites/CatalogFavoritesView'; +import { CatalogGiftView } from './views/gift/CatalogGiftView'; +import { CatalogNavigationView } from './views/navigation/CatalogNavigationView'; +import { CatalogSearchView } from './views/page/common/CatalogSearchView'; +import { GetCatalogLayout } from './views/page/layout/GetCatalogLayout'; +import { MarketplacePostOfferView } from './views/page/layout/marketplace/MarketplacePostOfferView'; + +const CatalogModernViewInner: FC<{}> = () => +{ + const { isVisible = false, setIsVisible = null, rootNode = null, currentPage = null, navigationHidden = false, setNavigationHidden = null, activeNodes = [], searchResult = null, setSearchResult = null, openPageByName = null, openPageByOfferId = null, activateNode = null } = useCatalog(); + const catalogAdmin = useCatalogAdmin(); + const adminMode = catalogAdmin?.adminMode ?? false; + const setAdminMode = catalogAdmin?.setAdminMode ?? (() => {}); + const hasPendingChanges = catalogAdmin?.hasPendingChanges ?? false; + const publishCatalog = catalogAdmin?.publishCatalog ?? (() => {}); + const loading = catalogAdmin?.loading ?? false; + const { favoriteOfferIds, favoritePageIds } = useCatalogFavorites(); + const [ showFavorites, setShowFavorites ] = useState(false); + + const isMod = GetSessionDataManager().isModerator; + const totalFavs = favoriteOfferIds.length + favoritePageIds.length; + + useEffect(() => + { + const linkTracker: ILinkEventTracker = { + linkReceived: (url: string) => + { + const parts = url.split('/'); + + if(parts.length < 2) return; + + switch(parts[1]) + { + case 'show': + setIsVisible(true); + return; + case 'hide': + setIsVisible(false); + return; + case 'toggle': + setIsVisible(prevValue => !prevValue); + return; + case 'open': + if(parts.length > 2) + { + if(parts.length === 4) + { + switch(parts[2]) + { + case 'offerId': + openPageByOfferId(parseInt(parts[3])); + return; + } + } + else + { + openPageByName(parts[2]); + } + } + else + { + setIsVisible(true); + } + + return; + } + }, + eventUrlPrefix: 'catalog/' + }; + + AddLinkEventTracker(linkTracker); + + return () => RemoveLinkEventTracker(linkTracker); + }, [ setIsVisible, openPageByOfferId, openPageByName ]); + + return ( + <> + { isVisible && + + setIsVisible(false) } /> + + { /* 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/CatalogView.tsx b/src/components/catalog/CatalogView.tsx index fc5f633..e685131 100644 --- a/src/components/catalog/CatalogView.tsx +++ b/src/components/catalog/CatalogView.tsx @@ -1,111 +1,13 @@ -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 { CatalogIconView } from './views/catalog-icon/CatalogIconView'; -import { CatalogGiftView } from './views/gift/CatalogGiftView'; -import { CatalogNavigationView } from './views/navigation/CatalogNavigationView'; -import { GetCatalogLayout } from './views/page/layout/GetCatalogLayout'; -import { MarketplacePostOfferView } from './views/page/layout/marketplace/MarketplacePostOfferView'; +import { FC } from 'react'; +import { GetConfigurationValue } from '../../api'; +import { CatalogClassicView } from './CatalogClassicView'; +import { CatalogModernView } from './CatalogModernView'; -export const CatalogView: FC<{}> = props => +export const CatalogView: FC<{}> = () => { - const { isVisible = false, setIsVisible = null, rootNode = null, currentPage = null, navigationHidden = false, setNavigationHidden = null, activeNodes = [], searchResult = null, setSearchResult = null, openPageByName = null, openPageByOfferId = null, activateNode = null, getNodeById } = useCatalog(); + const style = GetConfigurationValue('catalog.style', 'classic'); - useEffect(() => - { - const linkTracker: ILinkEventTracker = { - linkReceived: (url: string) => - { - const parts = url.split('/'); + if(style === 'new') return ; - if(parts.length < 2) return; - - switch(parts[1]) - { - case 'show': - setIsVisible(true); - return; - case 'hide': - setIsVisible(false); - return; - case 'toggle': - setIsVisible(prevValue => !prevValue); - return; - case 'open': - if(parts.length > 2) - { - if(parts.length === 4) - { - switch(parts[2]) - { - case 'offerId': - openPageByOfferId(parseInt(parts[3])); - return; - } - } - else - { - openPageByName(parts[2]); - } - } - else - { - setIsVisible(true); - } - - return; - } - }, - eventUrlPrefix: 'catalog/' - }; - - AddLinkEventTracker(linkTracker); - - return () => RemoveLinkEventTracker(linkTracker); - }, [ setIsVisible, openPageByOfferId, openPageByName ]); - - return ( - <> - { isVisible && - - setIsVisible(false) } /> - - { rootNode && (rootNode.children.length > 0) && rootNode.children.map((child, index) => - { - if(!child.isVisible) return null; - - return ( - - { - if(searchResult) setSearchResult(null); - - activateNode(child); - } } > -
- { GetConfigurationValue('catalog.tab.icons') && } - { child.localization } -
-
- ); - }) } -
- - - { !navigationHidden && - - { activeNodes && (activeNodes.length > 0) && - } - } - - { GetCatalogLayout(currentPage, () => setNavigationHidden(true)) } - - - -
} - - - - ); + 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..d31d801 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 { FC, useCallback, useRef, useState } from 'react'; +import { FaArrowsAlt, 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,122 @@ 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; + const [ isDragOver, setIsDragOver ] = useState(false); + const dragRef = useRef(null); + + const handleDragStart = useCallback((e: React.DragEvent) => + { + if(!adminMode) return; + + e.dataTransfer.setData('text/plain', JSON.stringify({ pageId: node.pageId, parentId: node.parent?.pageId ?? -1 })); + e.dataTransfer.effectAllowed = 'move'; + }, [ adminMode, node ]); + + const handleDragOver = useCallback((e: React.DragEvent) => + { + if(!adminMode) return; + + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setIsDragOver(true); + }, [ adminMode ]); + + const handleDragLeave = useCallback(() => + { + setIsDragOver(false); + }, []); + + const handleDrop = useCallback((e: React.DragEvent) => + { + if(!adminMode) return; + + e.preventDefault(); + setIsDragOver(false); + + try + { + const data = JSON.parse(e.dataTransfer.getData('text/plain')); + + if(data.pageId && data.pageId !== node.pageId) + { + // Drop onto a branch = reparent under this node + // Drop onto a leaf = reorder as sibling + const targetParentId = node.isBranch ? node.pageId : (node.parent?.pageId ?? -1); + const targetIndex = node.isBranch ? 0 : (node.parent?.children?.indexOf(node) ?? 0); + + catalogAdmin?.reorderPage(data.pageId, targetParentId, targetIndex); + } + } + catch(err) + { + // Invalid drag data + } + }, [ adminMode, node, catalogAdmin ]); return ( -
- activateNode(node) }> - - { node.localization } +
+
activateNode(node) } + onDragLeave={ adminMode ? handleDragLeave : undefined } + onDragOver={ adminMode ? handleDragOver : undefined } + onDragStart={ adminMode ? handleDragStart : undefined } + onDrop={ adminMode ? handleDrop : undefined } + > + { adminMode && + } +
+ +
+ { 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 ( - - - -