From 17da8a44072f897cfe851bdcd1285603c5cb2cfd Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sat, 21 Mar 2026 21:39:03 +0000 Subject: [PATCH] Added better listing and listing varient support. --- assets/icons/listingicon.svg | 14 +- assets/icons/listingvarienticon.svg | 19 ++ src/App.jsx | 5 + .../ListingVarients/ListingVarientInfo.jsx | 253 +++++++++++++++ .../ListingVarients/NewListingVarient.jsx | 87 +++++ .../ListingVarients/PublishListingVarient.jsx | 54 ++++ .../UnpublishListingVarient.jsx | 54 ++++ src/components/Dashboard/Sales/Listings.jsx | 107 +++++++ .../Dashboard/Sales/Listings/ListingInfo.jsx | 298 +++++++++++++++++ .../Dashboard/Sales/Listings/NewListing.jsx | 87 +++++ .../Sales/Listings/PublishListing.jsx | 50 +++ .../Sales/Listings/UnpublishListing.jsx | 50 +++ .../Marketplaces/ConfigureMarketplace.jsx | 68 ++++ .../Marketplaces/MarketplaceAuthCallback.jsx | 186 +++++++++++ .../Sales/Marketplaces/MarketplaceInfo.jsx | 163 +++++++++- .../Sales/Marketplaces/NewMarketplace.jsx | 30 +- .../Sales/Marketplaces/SyncListings.jsx | 46 +++ .../Sales/Marketplaces/SyncOrders.jsx | 46 +++ .../Dashboard/Sales/Marketplaces/authUtils.js | 68 ++++ .../Sales/SalesOrders/CancelSalesOrder.jsx | 1 - .../Sales/SalesOrders/ConfirmSalesOrder.jsx | 1 - .../Sales/SalesOrders/PostSalesOrder.jsx | 1 - .../Dashboard/Sales/SalesSidebar.jsx | 11 +- .../Dashboard/common/ObjectProperty.jsx | 16 + src/components/Dashboard/common/StateTag.jsx | 24 ++ .../Dashboard/common/UrlDisplay.jsx | 5 +- .../Dashboard/context/ApiServerContext.jsx | 36 ++- src/components/Icons/ListingIcon.jsx | 6 + src/components/Icons/ListingVarientIcon.jsx | 8 + src/database/ObjectModels.js | 10 +- src/database/models/Listing.js | 241 ++++++++++++++ src/database/models/ListingVarient.js | 243 ++++++++++++++ src/database/models/Marketplace.js | 302 +++++++++++++++--- src/routes/SalesRoutes.jsx | 44 ++- 34 files changed, 2545 insertions(+), 89 deletions(-) create mode 100644 assets/icons/listingvarienticon.svg create mode 100644 src/components/Dashboard/Sales/ListingVarients/ListingVarientInfo.jsx create mode 100644 src/components/Dashboard/Sales/ListingVarients/NewListingVarient.jsx create mode 100644 src/components/Dashboard/Sales/ListingVarients/PublishListingVarient.jsx create mode 100644 src/components/Dashboard/Sales/ListingVarients/UnpublishListingVarient.jsx create mode 100644 src/components/Dashboard/Sales/Listings.jsx create mode 100644 src/components/Dashboard/Sales/Listings/ListingInfo.jsx create mode 100644 src/components/Dashboard/Sales/Listings/NewListing.jsx create mode 100644 src/components/Dashboard/Sales/Listings/PublishListing.jsx create mode 100644 src/components/Dashboard/Sales/Listings/UnpublishListing.jsx create mode 100644 src/components/Dashboard/Sales/Marketplaces/ConfigureMarketplace.jsx create mode 100644 src/components/Dashboard/Sales/Marketplaces/MarketplaceAuthCallback.jsx create mode 100644 src/components/Dashboard/Sales/Marketplaces/SyncListings.jsx create mode 100644 src/components/Dashboard/Sales/Marketplaces/SyncOrders.jsx create mode 100644 src/components/Dashboard/Sales/Marketplaces/authUtils.js create mode 100644 src/components/Icons/ListingIcon.jsx create mode 100644 src/components/Icons/ListingVarientIcon.jsx create mode 100644 src/database/models/Listing.js create mode 100644 src/database/models/ListingVarient.js diff --git a/assets/icons/listingicon.svg b/assets/icons/listingicon.svg index 25fc30b..ed412f6 100644 --- a/assets/icons/listingicon.svg +++ b/assets/icons/listingicon.svg @@ -1,16 +1,10 @@ - - - - - - - - - - + + + + diff --git a/assets/icons/listingvarienticon.svg b/assets/icons/listingvarienticon.svg new file mode 100644 index 0000000..34082ec --- /dev/null +++ b/assets/icons/listingvarienticon.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/App.jsx b/src/App.jsx index 8a7ca80..fa096b1 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -30,6 +30,7 @@ import { ElectronProvider } from './components/Dashboard/context/ElectronContext import { MessageProvider } from './components/Dashboard/context/MessageContext.jsx' import AuthCallback from './components/App/AuthCallback.jsx' import EmailNotificationTemplate from './components/Email/EmailNotificationTemplate.jsx' +import MarketplaceAuthCallback from './components/Dashboard/Sales/Marketplaces/MarketplaceAuthCallback.jsx' import { ProductionRoutes, @@ -101,6 +102,10 @@ const AppContent = () => { path='/auth/callback' element={} /> + } + /> } diff --git a/src/components/Dashboard/Sales/ListingVarients/ListingVarientInfo.jsx b/src/components/Dashboard/Sales/ListingVarients/ListingVarientInfo.jsx new file mode 100644 index 0000000..b7a7db4 --- /dev/null +++ b/src/components/Dashboard/Sales/ListingVarients/ListingVarientInfo.jsx @@ -0,0 +1,253 @@ +import { useRef, useState } from 'react' +import { useLocation } from 'react-router-dom' +import { Space, Flex, Modal } from 'antd' +import loglevel from 'loglevel' +import config from '../../../../config' +import useCollapseState from '../../hooks/useCollapseState' +import NotesPanel from '../../common/NotesPanel' +import InfoCollapse from '../../common/InfoCollapse' +import ObjectInfo from '../../common/ObjectInfo' +import ViewButton from '../../common/ViewButton' +import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx' +import NoteIcon from '../../../Icons/NoteIcon.jsx' +import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx' +import ObjectForm from '../../common/ObjectForm' +import EditButtons from '../../common/EditButtons' +import LockIndicator from '../../common/LockIndicator.jsx' +import ActionHandler from '../../common/ActionHandler.jsx' +import ObjectActions from '../../common/ObjectActions.jsx' +import ObjectTable from '../../common/ObjectTable.jsx' +import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx' +import DocumentPrintButton from '../../common/DocumentPrintButton.jsx' +import UserNotifierToggle from '../../common/UserNotifierToggle.jsx' +import ScrollBox from '../../common/ScrollBox.jsx' +import PublishListingVarient from './PublishListingVarient.jsx' +import UnpublishListingVarient from './UnpublishListingVarient.jsx' +import { Card } from 'antd' + +const log = loglevel.getLogger('ListingVarientInfo') +log.setLevel(config.logLevel) + +const ListingVarientInfo = () => { + const location = useLocation() + const objectFormRef = useRef(null) + const actionHandlerRef = useRef(null) + const listingVarientId = new URLSearchParams(location.search).get( + 'listingVarientId' + ) + const [publishOpen, setPublishOpen] = useState(false) + const [unpublishOpen, setUnpublishOpen] = useState(false) + const [collapseState, updateCollapseState] = useCollapseState( + 'ListingVarientInfo', + { + info: true, + notes: true, + auditLogs: true + } + ) + const [objectFormState, setEditFormState] = useState({ + isEditing: false, + editLoading: false, + formValid: false, + lock: null, + loading: false, + objectData: {} + }) + + const actions = { + reload: () => { + objectFormRef?.current?.handleFetchObject?.() + return true + }, + edit: () => { + objectFormRef?.current?.startEditing?.() + return false + }, + cancelEdit: () => { + objectFormRef?.current?.cancelEditing?.() + return true + }, + finishEdit: () => { + objectFormRef?.current?.handleUpdate?.() + return true + }, + delete: () => { + objectFormRef?.current?.handleDelete?.() + return true + }, + publish: () => { + setPublishOpen(true) + return true + }, + unpublish: () => { + setUnpublishOpen(true) + return true + } + } + + return ( + <> + + + + + + + + + + + + + { + actionHandlerRef.current.callAction('finishEdit') + }} + cancelEditing={() => { + actionHandlerRef.current.callAction('cancelEdit') + }} + startEditing={() => { + actionHandlerRef.current.callAction('edit') + }} + editLoading={objectFormState.editLoading} + formValid={objectFormState.formValid} + disabled={objectFormState.lock?.locked || objectFormState.loading} + loading={objectFormState.editLoading} + /> + + + + + + { + setEditFormState((prev) => ({ ...prev, ...state })) + }} + > + {({ loading, isEditing, objectData }) => ( + + } + active={collapseState.info} + onToggle={(expanded) => + updateCollapseState('info', expanded) + } + collapseKey='info' + > + + + + )} + + + } + active={collapseState.notes} + onToggle={(expanded) => updateCollapseState('notes', expanded)} + collapseKey='notes' + > + + + + + } + active={collapseState.auditLogs} + onToggle={(expanded) => + updateCollapseState('auditLogs', expanded) + } + collapseKey='auditLogs' + > + {objectFormState.loading ? ( + + ) : ( + + )} + + + + + setPublishOpen(false)} + width={515} + footer={null} + destroyOnHidden={true} + centered={true} + > + { + setPublishOpen(false) + actions.reload() + }} + /> + + setUnpublishOpen(false)} + width={515} + footer={null} + destroyOnHidden={true} + centered={true} + > + { + setUnpublishOpen(false) + actions.reload() + }} + /> + + + ) +} + +export default ListingVarientInfo diff --git a/src/components/Dashboard/Sales/ListingVarients/NewListingVarient.jsx b/src/components/Dashboard/Sales/ListingVarients/NewListingVarient.jsx new file mode 100644 index 0000000..87abcb6 --- /dev/null +++ b/src/components/Dashboard/Sales/ListingVarients/NewListingVarient.jsx @@ -0,0 +1,87 @@ +import PropTypes from 'prop-types' +import ObjectInfo from '../../common/ObjectInfo' +import NewObjectForm from '../../common/NewObjectForm' +import WizardView from '../../common/WizardView' + +const NewListingVarient = ({ onOk, defaultValues }) => { + return ( + + {({ handleSubmit, submitLoading, objectData, formValid }) => { + const steps = [ + { + title: 'Required', + key: 'required', + content: ( + + ) + }, + { + title: 'Optional', + key: 'optional', + content: ( + + ) + }, + { + title: 'Summary', + key: 'summary', + content: ( + + ) + } + ] + return ( + { + const result = await handleSubmit() + if (result) { + onOk() + } + }} + /> + ) + }} + + ) +} + +NewListingVarient.propTypes = { + onOk: PropTypes.func.isRequired, + reset: PropTypes.bool, + defaultValues: PropTypes.object +} + +export default NewListingVarient diff --git a/src/components/Dashboard/Sales/ListingVarients/PublishListingVarient.jsx b/src/components/Dashboard/Sales/ListingVarients/PublishListingVarient.jsx new file mode 100644 index 0000000..e6c0910 --- /dev/null +++ b/src/components/Dashboard/Sales/ListingVarients/PublishListingVarient.jsx @@ -0,0 +1,54 @@ +import { useState, useContext } from 'react' +import PropTypes from 'prop-types' +import { ApiServerContext } from '../../context/ApiServerContext' +import { message } from 'antd' +import MessageDialogView from '../../common/MessageDialogView.jsx' + +const PublishListingVarient = ({ onOk, objectData }) => { + const [loading, setLoading] = useState(false) + const { sendObjectFunction } = useContext(ApiServerContext) + + const handlePublish = async () => { + setLoading(true) + try { + const result = await sendObjectFunction( + objectData._id, + 'ListingVarient', + 'publish' + ) + if (result) { + message.success('Published successfully') + onOk(result) + } + } catch (error) { + console.error('Error publishing listing variant:', error) + } finally { + setLoading(false) + } + } + + const ref = + objectData?.title || + objectData?._reference || + objectData?.name || + objectData?._id + + return ( + + ) +} + +PublishListingVarient.propTypes = { + onOk: PropTypes.func.isRequired, + objectData: PropTypes.object +} + +export default PublishListingVarient diff --git a/src/components/Dashboard/Sales/ListingVarients/UnpublishListingVarient.jsx b/src/components/Dashboard/Sales/ListingVarients/UnpublishListingVarient.jsx new file mode 100644 index 0000000..ae39061 --- /dev/null +++ b/src/components/Dashboard/Sales/ListingVarients/UnpublishListingVarient.jsx @@ -0,0 +1,54 @@ +import { useState, useContext } from 'react' +import PropTypes from 'prop-types' +import { ApiServerContext } from '../../context/ApiServerContext' +import { message } from 'antd' +import MessageDialogView from '../../common/MessageDialogView.jsx' + +const UnpublishListingVarient = ({ onOk, objectData }) => { + const [loading, setLoading] = useState(false) + const { sendObjectFunction } = useContext(ApiServerContext) + + const handleUnpublish = async () => { + setLoading(true) + try { + const result = await sendObjectFunction( + objectData._id, + 'ListingVarient', + 'unpublish' + ) + if (result) { + message.success('Unpublished successfully') + onOk(result) + } + } catch (error) { + console.error('Error unpublishing listing variant:', error) + } finally { + setLoading(false) + } + } + + const ref = + objectData?.title || + objectData?._reference || + objectData?.name || + objectData?._id + + return ( + + ) +} + +UnpublishListingVarient.propTypes = { + onOk: PropTypes.func.isRequired, + objectData: PropTypes.object +} + +export default UnpublishListingVarient diff --git a/src/components/Dashboard/Sales/Listings.jsx b/src/components/Dashboard/Sales/Listings.jsx new file mode 100644 index 0000000..fb7b4bd --- /dev/null +++ b/src/components/Dashboard/Sales/Listings.jsx @@ -0,0 +1,107 @@ +import { useState, useRef } from 'react' +import { Button, Flex, Space, Modal, Dropdown } from 'antd' +import NewListing from './Listings/NewListing' +import ObjectTable from '../common/ObjectTable' +import PlusIcon from '../../Icons/PlusIcon' +import ReloadIcon from '../../Icons/ReloadIcon' +import useColumnVisibility from '../hooks/useColumnVisibility' +import ObjectTableViewButton from '../common/ObjectTableViewButton' +import FilterSidebarButton from '../common/FilterSidebarButton' +import useViewMode from '../hooks/useViewMode' +import useFilterSidebarVisibility from '../hooks/useFilterSidebarVisibility' +import ColumnViewButton from '../common/ColumnViewButton' +import ExportListButton from '../common/ExportListButton' + +const Listings = () => { + const [newListingOpen, setNewListingOpen] = useState(false) + const tableRef = useRef() + + const [viewMode, setViewMode] = useViewMode('listings') + + const [columnVisibility, setColumnVisibility] = + useColumnVisibility('listings') + + const [showFilterSidebar, setShowFilterSidebar] = + useFilterSidebarVisibility('Listings') + + const actionItems = { + items: [ + { + label: 'New Listing', + key: 'newListing', + icon: + }, + { type: 'divider' }, + { + label: 'Reload List', + key: 'reloadList', + icon: + } + ], + onClick: ({ key }) => { + if (key === 'reloadList') { + tableRef.current?.reload() + } else if (key === 'newListing') { + setNewListingOpen(true) + } + } + } + + return ( + <> + + + + + + + + + + + setShowFilterSidebar(!showFilterSidebar)} + /> + + + + + + { + setNewListingOpen(false) + }} + destroyOnHidden={true} + > + { + setNewListingOpen(false) + tableRef.current?.reload() + }} + reset={newListingOpen} + /> + + + ) +} + +export default Listings diff --git a/src/components/Dashboard/Sales/Listings/ListingInfo.jsx b/src/components/Dashboard/Sales/Listings/ListingInfo.jsx new file mode 100644 index 0000000..a5b1609 --- /dev/null +++ b/src/components/Dashboard/Sales/Listings/ListingInfo.jsx @@ -0,0 +1,298 @@ +import { useRef, useState } from 'react' +import { useLocation } from 'react-router-dom' +import { Space, Flex, Modal } from 'antd' +import loglevel from 'loglevel' +import config from '../../../../config' +import useCollapseState from '../../hooks/useCollapseState' +import NotesPanel from '../../common/NotesPanel' +import InfoCollapse from '../../common/InfoCollapse' +import ObjectInfo from '../../common/ObjectInfo' +import ViewButton from '../../common/ViewButton' +import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx' +import NoteIcon from '../../../Icons/NoteIcon.jsx' +import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx' +import ListingVarientIcon from '../../../Icons/ListingVarientIcon.jsx' +import ObjectForm from '../../common/ObjectForm' +import EditButtons from '../../common/EditButtons' +import LockIndicator from '../../common/LockIndicator.jsx' +import ActionHandler from '../../common/ActionHandler.jsx' +import ObjectActions from '../../common/ObjectActions.jsx' +import ObjectTable from '../../common/ObjectTable.jsx' +import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx' +import DocumentPrintButton from '../../common/DocumentPrintButton.jsx' +import UserNotifierToggle from '../../common/UserNotifierToggle.jsx' +import ScrollBox from '../../common/ScrollBox.jsx' +import NewListingVarient from '../ListingVarients/NewListingVarient.jsx' +import PublishListing from './PublishListing.jsx' +import UnpublishListing from './UnpublishListing.jsx' +import { Card } from 'antd' + +const log = loglevel.getLogger('ListingInfo') +log.setLevel(config.logLevel) + +const ListingInfo = () => { + const location = useLocation() + const objectFormRef = useRef(null) + const listingVarientsTableRef = useRef(null) + const actionHandlerRef = useRef(null) + const listingId = new URLSearchParams(location.search).get('listingId') + const [newListingVarientOpen, setNewListingVarientOpen] = useState(false) + const [publishListingOpen, setPublishListingOpen] = useState(false) + const [unpublishListingOpen, setUnpublishListingOpen] = useState(false) + const [collapseState, updateCollapseState] = useCollapseState('ListingInfo', { + info: true, + listingVarients: true, + notes: true, + auditLogs: true + }) + const [objectFormState, setEditFormState] = useState({ + isEditing: false, + editLoading: false, + formValid: false, + lock: null, + loading: false, + objectData: {} + }) + + const actions = { + reload: () => { + objectFormRef?.current?.handleFetchObject?.() + return true + }, + edit: () => { + objectFormRef?.current?.startEditing?.() + return false + }, + cancelEdit: () => { + objectFormRef?.current?.cancelEditing?.() + return true + }, + finishEdit: () => { + objectFormRef?.current?.handleUpdate?.() + return true + }, + delete: () => { + objectFormRef?.current?.handleDelete?.() + return true + }, + newListingVarient: () => { + setNewListingVarientOpen(true) + return true + }, + publish: () => { + setPublishListingOpen(true) + return true + }, + unpublish: () => { + setUnpublishListingOpen(true) + return true + } + } + + return ( + <> + + + + + + + + + + + + + { + actionHandlerRef.current.callAction('finishEdit') + }} + cancelEditing={() => { + actionHandlerRef.current.callAction('cancelEdit') + }} + startEditing={() => { + actionHandlerRef.current.callAction('edit') + }} + editLoading={objectFormState.editLoading} + formValid={objectFormState.formValid} + disabled={objectFormState.lock?.locked || objectFormState.loading} + loading={objectFormState.editLoading} + /> + + + + + + { + setEditFormState((prev) => ({ ...prev, ...state })) + }} + > + {({ loading, isEditing, objectData }) => ( + + } + active={collapseState.info} + onToggle={(expanded) => + updateCollapseState('info', expanded) + } + collapseKey='info' + > + + + } + active={collapseState.listingVarients} + onToggle={(expanded) => + updateCollapseState('listingVarients', expanded) + } + collapseKey='listingVarients' + > + + + + )} + + + } + active={collapseState.notes} + onToggle={(expanded) => updateCollapseState('notes', expanded)} + collapseKey='notes' + > + + + + + } + active={collapseState.auditLogs} + onToggle={(expanded) => + updateCollapseState('auditLogs', expanded) + } + collapseKey='auditLogs' + > + {objectFormState.loading ? ( + + ) : ( + + )} + + + + + { + setNewListingVarientOpen(false) + }} + width={800} + footer={null} + destroyOnHidden={true} + > + { + setNewListingVarientOpen(false) + listingVarientsTableRef.current?.reload() + }} + reset={newListingVarientOpen} + defaultValues={{ + listing: { _id: listingId } + }} + /> + + setPublishListingOpen(false)} + width={515} + footer={null} + destroyOnHidden={true} + centered={true} + > + { + setPublishListingOpen(false) + actions.reload() + listingVarientsTableRef.current?.reload?.() + }} + /> + + setUnpublishListingOpen(false)} + width={515} + footer={null} + destroyOnHidden={true} + centered={true} + > + { + setUnpublishListingOpen(false) + actions.reload() + listingVarientsTableRef.current?.reload?.() + }} + /> + + + ) +} + +export default ListingInfo diff --git a/src/components/Dashboard/Sales/Listings/NewListing.jsx b/src/components/Dashboard/Sales/Listings/NewListing.jsx new file mode 100644 index 0000000..6ee5f8d --- /dev/null +++ b/src/components/Dashboard/Sales/Listings/NewListing.jsx @@ -0,0 +1,87 @@ +import PropTypes from 'prop-types' +import ObjectInfo from '../../common/ObjectInfo' +import NewObjectForm from '../../common/NewObjectForm' +import WizardView from '../../common/WizardView' + +const NewListing = ({ onOk, defaultValues }) => { + return ( + + {({ handleSubmit, submitLoading, objectData, formValid }) => { + const steps = [ + { + title: 'Required', + key: 'required', + content: ( + + ) + }, + { + title: 'Optional', + key: 'optional', + content: ( + + ) + }, + { + title: 'Summary', + key: 'summary', + content: ( + + ) + } + ] + return ( + { + const result = await handleSubmit() + if (result) { + onOk() + } + }} + /> + ) + }} + + ) +} + +NewListing.propTypes = { + onOk: PropTypes.func.isRequired, + reset: PropTypes.bool, + defaultValues: PropTypes.object +} + +export default NewListing diff --git a/src/components/Dashboard/Sales/Listings/PublishListing.jsx b/src/components/Dashboard/Sales/Listings/PublishListing.jsx new file mode 100644 index 0000000..6e35655 --- /dev/null +++ b/src/components/Dashboard/Sales/Listings/PublishListing.jsx @@ -0,0 +1,50 @@ +import { useState, useContext } from 'react' +import PropTypes from 'prop-types' +import { ApiServerContext } from '../../context/ApiServerContext' +import { message } from 'antd' +import MessageDialogView from '../../common/MessageDialogView.jsx' + +const PublishListing = ({ onOk, objectData }) => { + const [loading, setLoading] = useState(false) + const { sendObjectFunction } = useContext(ApiServerContext) + + const handlePublish = async () => { + setLoading(true) + try { + const result = await sendObjectFunction(objectData._id, 'Listing', 'publish') + if (result) { + message.success('Published successfully') + onOk(result) + } + } catch (error) { + console.error('Error publishing listing:', error) + } finally { + setLoading(false) + } + } + + const ref = + objectData?.title || + objectData?._reference || + objectData?.name || + objectData?._id + + return ( + + ) +} + +PublishListing.propTypes = { + onOk: PropTypes.func.isRequired, + objectData: PropTypes.object +} + +export default PublishListing diff --git a/src/components/Dashboard/Sales/Listings/UnpublishListing.jsx b/src/components/Dashboard/Sales/Listings/UnpublishListing.jsx new file mode 100644 index 0000000..732be52 --- /dev/null +++ b/src/components/Dashboard/Sales/Listings/UnpublishListing.jsx @@ -0,0 +1,50 @@ +import { useState, useContext } from 'react' +import PropTypes from 'prop-types' +import { ApiServerContext } from '../../context/ApiServerContext' +import { message } from 'antd' +import MessageDialogView from '../../common/MessageDialogView.jsx' + +const UnpublishListing = ({ onOk, objectData }) => { + const [loading, setLoading] = useState(false) + const { sendObjectFunction } = useContext(ApiServerContext) + + const handleUnpublish = async () => { + setLoading(true) + try { + const result = await sendObjectFunction(objectData._id, 'Listing', 'unpublish') + if (result) { + message.success('Unpublished successfully') + onOk(result) + } + } catch (error) { + console.error('Error unpublishing listing:', error) + } finally { + setLoading(false) + } + } + + const ref = + objectData?.title || + objectData?._reference || + objectData?.name || + objectData?._id + + return ( + + ) +} + +UnpublishListing.propTypes = { + onOk: PropTypes.func.isRequired, + objectData: PropTypes.object +} + +export default UnpublishListing diff --git a/src/components/Dashboard/Sales/Marketplaces/ConfigureMarketplace.jsx b/src/components/Dashboard/Sales/Marketplaces/ConfigureMarketplace.jsx new file mode 100644 index 0000000..e7b13c2 --- /dev/null +++ b/src/components/Dashboard/Sales/Marketplaces/ConfigureMarketplace.jsx @@ -0,0 +1,68 @@ +import PropTypes from 'prop-types' +import { Result, Typography, Flex, Button } from 'antd' +import CopyButton from '../../common/CopyButton' +import MarketplaceIcon from '../../../Icons/MarketplaceIcon' +import { getMarketplaceCallbackUrl } from './authUtils' + +const { Text } = Typography + +const ConfigureMarketplace = ({ + onConnect, + isConnected = false, + loading = false, + disabled = false +}) => { + const callbackUrl = getMarketplaceCallbackUrl() + + return ( + + + Use this callback URL for TikTok Shop redirects and as the eBay + RuName accept URL target. Click Connect to authorize your + marketplace account. + + } + icon={} + > + + + + + + {callbackUrl} + + + + + + + + + + ) +} + +ConfigureMarketplace.propTypes = { + onConnect: PropTypes.func, + isConnected: PropTypes.bool, + loading: PropTypes.bool, + disabled: PropTypes.bool +} + +export default ConfigureMarketplace diff --git a/src/components/Dashboard/Sales/Marketplaces/MarketplaceAuthCallback.jsx b/src/components/Dashboard/Sales/Marketplaces/MarketplaceAuthCallback.jsx new file mode 100644 index 0000000..3b38115 --- /dev/null +++ b/src/components/Dashboard/Sales/Marketplaces/MarketplaceAuthCallback.jsx @@ -0,0 +1,186 @@ +import { useContext, useEffect, useMemo, useState } from 'react' +import { Alert, Button, Card, Flex, message } from 'antd' +import CheckIcon from '../../../Icons/CheckIcon' +import { useLocation, useNavigate } from 'react-router-dom' +import { LoadingOutlined } from '@ant-design/icons' +import { ApiServerContext } from '../../context/ApiServerContext' +import { + clearMarketplaceAuthState, + parseMarketplaceAuthState, + readMarketplaceAuthState +} from './authUtils' +import { AuthContext } from '../../context/AuthContext' +import AuthParticles from '../../../App/AppParticles' +import FarmControlLogo from '../../../Logos/FarmControlLogo' +import ExclamationOctagonIcon from '../../../Icons/ExclamationOctagonIcon' +import ArrowLeftIcon from '../../../Icons/ArrowLeftIcon' + +const MarketplaceAuthCallback = () => { + const location = useLocation() + const navigate = useNavigate() + const { token } = useContext(AuthContext) + const { sendObjectFunction, connected } = useContext(ApiServerContext) + const [status, setStatus] = useState('loading') + const [redirectLoading, setRedirectLoading] = useState(false) + const [resultMessage, setResultMessage] = useState('') + + const params = useMemo( + () => new URLSearchParams(location.search), + [location.search] + ) + const rawState = params.get('state') || '' + const parsedState = parseMarketplaceAuthState(rawState) + const storedState = readMarketplaceAuthState(rawState) + const marketplaceId = + params.get('marketplaceId') || + parsedState?.marketplaceId || + storedState?.marketplaceId || + '' + const returnTo = + parsedState?.returnTo || + storedState?.returnTo || + (marketplaceId + ? `/dashboard/sales/marketplaces/info?marketplaceId=${marketplaceId}` + : '/dashboard/sales/marketplaces') + + useEffect(() => { + if (!connected || token == null || !token) { + return + } + + const error = params.get('error') || params.get('error_description') + const code = params.get('code') || params.get('auth_code') + + if (error) { + setStatus('error') + setResultMessage(error) + if (rawState) { + clearMarketplaceAuthState(rawState) + } + return + } + + if (!marketplaceId) { + setStatus('error') + setResultMessage('Could not determine which marketplace to connect.') + return + } + + if (!code) { + setStatus('error') + setResultMessage('Authorization code was not returned by the provider.') + return + } + + let cancelled = false + + const exchangeCode = async () => { + try { + const result = await sendObjectFunction( + marketplaceId, + 'Marketplace', + 'auth/exchange', + { + code, + state: rawState + } + ) + if (!result?.success) { + throw new Error( + result?.error || 'Failed to complete marketplace authorization.' + ) + } + + if (cancelled) { + return + } + + setStatus('success') + setResultMessage('Marketplace authorization completed successfully.') + message.success('Marketplace connected') + if (rawState) { + clearMarketplaceAuthState(rawState) + } + } catch (error) { + if (cancelled) { + return + } + + console.error('Marketplace auth callback failed:', error) + setStatus('error') + setResultMessage( + error?.response?.data?.error || + error?.message || + 'Failed to complete marketplace authorization.' + ) + } + } + + exchangeCode() + + return () => { + cancelled = true + } + }, [marketplaceId, params, rawState, sendObjectFunction, token, connected]) + + return ( + <> + + + + + + + + + {status != 'error' && status != 'success' && ( + } + type='info' + showIcon + /> + )} + + {status === 'success' && ( + } + type='success' + showIcon + /> + )} + + {status === 'error' && ( + } + type='error' + showIcon + /> + )} + + + + + + + ) +} + +export default MarketplaceAuthCallback diff --git a/src/components/Dashboard/Sales/Marketplaces/MarketplaceInfo.jsx b/src/components/Dashboard/Sales/Marketplaces/MarketplaceInfo.jsx index 9bd461f..3d53b46 100644 --- a/src/components/Dashboard/Sales/Marketplaces/MarketplaceInfo.jsx +++ b/src/components/Dashboard/Sales/Marketplaces/MarketplaceInfo.jsx @@ -1,6 +1,6 @@ -import { useRef, useState } from 'react' +import { useContext, useRef, useState } from 'react' import { useLocation } from 'react-router-dom' -import { Space, Flex } from 'antd' +import { Space, Flex, Modal, message } from 'antd' import loglevel from 'loglevel' import config from '../../../../config' import useCollapseState from '../../hooks/useCollapseState' @@ -22,6 +22,15 @@ import DocumentPrintButton from '../../common/DocumentPrintButton.jsx' import UserNotifierToggle from '../../common/UserNotifierToggle.jsx' import ScrollBox from '../../common/ScrollBox.jsx' import { Card } from 'antd' +import SyncListings from './SyncListings.jsx' +import SyncOrders from './SyncOrders.jsx' +import ConfigureMarketplace from './ConfigureMarketplace.jsx' +import { ApiServerContext } from '../../context/ApiServerContext.jsx' +import { ElectronContext } from '../../context/ElectronContext.jsx' +import { + buildMarketplaceAuthState, + storeMarketplaceAuthState +} from './authUtils.js' const log = loglevel.getLogger('MarketplaceInfo') log.setLevel(config.logLevel) @@ -30,12 +39,20 @@ const MarketplaceInfo = () => { const location = useLocation() const objectFormRef = useRef(null) const actionHandlerRef = useRef(null) - const marketplaceId = new URLSearchParams(location.search).get('marketplaceId') - const [collapseState, updateCollapseState] = useCollapseState('MarketplaceInfo', { - info: true, - notes: true, - auditLogs: true - }) + const { getMarketplaceAuthUrl, refreshMarketplaceAuth } = + useContext(ApiServerContext) + const { openExternalUrl } = useContext(ElectronContext) + const marketplaceId = new URLSearchParams(location.search).get( + 'marketplaceId' + ) + const [collapseState, updateCollapseState] = useCollapseState( + 'MarketplaceInfo', + { + info: true, + notes: true, + auditLogs: true + } + ) const [objectFormState, setEditFormState] = useState({ isEditing: false, editLoading: false, @@ -44,6 +61,55 @@ const MarketplaceInfo = () => { loading: false, objectData: {} }) + const [syncListingsOpen, setSyncListingsOpen] = useState(false) + const [syncOrdersOpen, setSyncOrdersOpen] = useState(false) + const [configureModalOpen, setConfigureModalOpen] = useState(false) + + const startAuthorization = async () => { + const objectData = objectFormState.objectData + if (!objectData?._id) return + + if (objectFormState.isEditing) { + message.warning('Save marketplace changes before starting authorization') + return + } + + const returnTo = `/dashboard/sales/marketplaces/info?marketplaceId=${objectData._id}` + const state = buildMarketplaceAuthState({ + marketplaceId: objectData._id, + returnTo + }) + + storeMarketplaceAuthState(state, { + marketplaceId: objectData._id, + returnTo + }) + + const result = await getMarketplaceAuthUrl(objectData._id, state) + + if (!result?.url) { + message.error('Authorization URL was not returned') + return + } + + const openedExternally = openExternalUrl(result.url) + if (!openedExternally) { + window.location.assign(result.url) + } + } + + const handleRefreshToken = async () => { + const objectData = objectFormState.objectData + if (!objectData?._id) return + + const result = await refreshMarketplaceAuth(objectData._id) + if (!result?.success) { + message.error(result?.error || 'Token refresh failed') + return + } + message.success('Marketplace token refreshed') + objectFormRef?.current?.handleFetchObject?.() + } const actions = { reload: () => { @@ -65,6 +131,30 @@ const MarketplaceInfo = () => { delete: () => { objectFormRef?.current?.handleDelete?.() return true + }, + syncListings: () => { + setSyncListingsOpen(true) + return true + }, + syncOrders: () => { + setSyncOrdersOpen(true) + return true + }, + configure: () => { + setConfigureModalOpen(true) + return true + }, + connect: () => { + startAuthorization() + return true + }, + reconnect: () => { + startAuthorization() + return true + }, + refreshToken: () => { + handleRefreshToken() + return true } } @@ -154,6 +244,7 @@ const MarketplaceInfo = () => { loading={loading} isEditing={isEditing} type='marketplace' + labelWidth={215} objectData={objectData} /> )} @@ -193,6 +284,62 @@ const MarketplaceInfo = () => { + setSyncListingsOpen(false)} + width={500} + footer={null} + destroyOnClose + centered + > + { + setSyncListingsOpen(false) + actions.reload() + }} + objectData={objectFormState.objectData} + /> + + setSyncOrdersOpen(false)} + width={500} + footer={null} + destroyOnClose + centered + > + { + setSyncOrdersOpen(false) + actions.reload() + }} + objectData={objectFormState.objectData} + /> + + setConfigureModalOpen(false)} + width={650} + footer={null} + destroyOnClose + centered + > + { + startAuthorization() + setConfigureModalOpen(false) + }} + isConnected={ + !!( + objectFormState.objectData?.config?.refreshToken || + objectFormState.objectData?.config?.accessToken || + objectFormState.objectData?.config?.shopCipher + ) + } + loading={objectFormState.loading} + disabled={objectFormState.isEditing} + /> + ) } diff --git a/src/components/Dashboard/Sales/Marketplaces/NewMarketplace.jsx b/src/components/Dashboard/Sales/Marketplaces/NewMarketplace.jsx index 0221a1b..d69692d 100644 --- a/src/components/Dashboard/Sales/Marketplaces/NewMarketplace.jsx +++ b/src/components/Dashboard/Sales/Marketplaces/NewMarketplace.jsx @@ -2,12 +2,21 @@ import PropTypes from 'prop-types' import ObjectInfo from '../../common/ObjectInfo' import NewObjectForm from '../../common/NewObjectForm' import WizardView from '../../common/WizardView' +import { getMarketplaceCallbackUrl } from './authUtils' const NewMarketplace = ({ onOk, defaultValues }) => { + const callbackUrl = getMarketplaceCallbackUrl() + return ( {({ handleSubmit, submitLoading, objectData, formValid }) => { const steps = [ @@ -60,14 +69,23 @@ const NewMarketplace = ({ onOk, defaultValues }) => { _id: false, createdAt: false, updatedAt: false, - 'config.appId': false, - 'config.certId': false, - 'config.devId': false, - 'config.userToken': false, + authConnected: false, + 'config.clientId': false, + 'config.clientSecret': false, + 'config.ruName': false, + 'config.marketplaceId': false, + 'config.sandbox': false, + 'config.verificationToken': false, + 'config.redirectUri': false, 'config.accessToken': false, 'config.refreshToken': false, + 'config.refreshTokenExpiresAt': false, + 'config.accessTokenExpiresAt': false, + 'config.lastTokenRefreshAt': false, + connectedAt: false, 'config.appKey': false, - 'config.appSecret': false + 'config.appSecret': false, + 'config.shopCipher': false }} isEditing={false} objectData={objectData} diff --git a/src/components/Dashboard/Sales/Marketplaces/SyncListings.jsx b/src/components/Dashboard/Sales/Marketplaces/SyncListings.jsx new file mode 100644 index 0000000..1eace47 --- /dev/null +++ b/src/components/Dashboard/Sales/Marketplaces/SyncListings.jsx @@ -0,0 +1,46 @@ +import { useState, useContext } from 'react' +import PropTypes from 'prop-types' +import { ApiServerContext } from '../../context/ApiServerContext' +import { message } from 'antd' +import MessageDialogView from '../../common/MessageDialogView.jsx' + +const SyncListings = ({ onOk, objectData }) => { + const [syncLoading, setSyncLoading] = useState(false) + const { sendObjectFunction } = useContext(ApiServerContext) + + const handleSync = async () => { + setSyncLoading(true) + try { + const result = await sendObjectFunction( + objectData._id, + 'Marketplace', + 'sync/items' + ) + if (result) { + message.success('Listings synced successfully') + onOk(result) + } + } catch (error) { + console.error('Error syncing listings:', error) + } finally { + setSyncLoading(false) + } + } + + return ( + + ) +} + +SyncListings.propTypes = { + onOk: PropTypes.func.isRequired, + objectData: PropTypes.object +} + +export default SyncListings diff --git a/src/components/Dashboard/Sales/Marketplaces/SyncOrders.jsx b/src/components/Dashboard/Sales/Marketplaces/SyncOrders.jsx new file mode 100644 index 0000000..e9f6fe1 --- /dev/null +++ b/src/components/Dashboard/Sales/Marketplaces/SyncOrders.jsx @@ -0,0 +1,46 @@ +import { useState, useContext } from 'react' +import PropTypes from 'prop-types' +import { ApiServerContext } from '../../context/ApiServerContext' +import { message } from 'antd' +import MessageDialogView from '../../common/MessageDialogView.jsx' + +const SyncOrders = ({ onOk, objectData }) => { + const [syncLoading, setSyncLoading] = useState(false) + const { sendObjectFunction } = useContext(ApiServerContext) + + const handleSync = async () => { + setSyncLoading(true) + try { + const result = await sendObjectFunction( + objectData._id, + 'Marketplace', + 'sync/orders' + ) + if (result) { + message.success('Orders synced successfully') + onOk(result) + } + } catch (error) { + console.error('Error syncing orders:', error) + } finally { + setSyncLoading(false) + } + } + + return ( + + ) +} + +SyncOrders.propTypes = { + onOk: PropTypes.func.isRequired, + objectData: PropTypes.object +} + +export default SyncOrders diff --git a/src/components/Dashboard/Sales/Marketplaces/authUtils.js b/src/components/Dashboard/Sales/Marketplaces/authUtils.js new file mode 100644 index 0000000..ef321a8 --- /dev/null +++ b/src/components/Dashboard/Sales/Marketplaces/authUtils.js @@ -0,0 +1,68 @@ +export const MARKETPLACE_AUTH_STATE_KEY = 'marketplace-auth-state' + +export function buildMarketplaceAuthState({ marketplaceId, returnTo } = {}) { + return btoa( + JSON.stringify({ + marketplaceId, + returnTo + }) + ) +} + +export function parseMarketplaceAuthState(rawState) { + if (!rawState) { + return null + } + + try { + return JSON.parse(atob(rawState)) + } catch { + return null + } +} + +export function getMarketplaceCallbackUrl() { + if (typeof window === 'undefined') { + return '' + } + + return `${window.location.origin}/auth/marketplace/callback` +} + +export function storeMarketplaceAuthState(rawState, data) { + if (typeof window === 'undefined' || !rawState) { + return + } + + window.sessionStorage.setItem( + `${MARKETPLACE_AUTH_STATE_KEY}:${rawState}`, + JSON.stringify(data) + ) +} + +export function readMarketplaceAuthState(rawState) { + if (typeof window === 'undefined' || !rawState) { + return null + } + + const stored = window.sessionStorage.getItem( + `${MARKETPLACE_AUTH_STATE_KEY}:${rawState}` + ) + if (!stored) { + return null + } + + try { + return JSON.parse(stored) + } catch { + return null + } +} + +export function clearMarketplaceAuthState(rawState) { + if (typeof window === 'undefined' || !rawState) { + return + } + + window.sessionStorage.removeItem(`${MARKETPLACE_AUTH_STATE_KEY}:${rawState}`) +} diff --git a/src/components/Dashboard/Sales/SalesOrders/CancelSalesOrder.jsx b/src/components/Dashboard/Sales/SalesOrders/CancelSalesOrder.jsx index f6adb7b..4ff617f 100644 --- a/src/components/Dashboard/Sales/SalesOrders/CancelSalesOrder.jsx +++ b/src/components/Dashboard/Sales/SalesOrders/CancelSalesOrder.jsx @@ -44,4 +44,3 @@ CancelSalesOrder.propTypes = { } export default CancelSalesOrder - diff --git a/src/components/Dashboard/Sales/SalesOrders/ConfirmSalesOrder.jsx b/src/components/Dashboard/Sales/SalesOrders/ConfirmSalesOrder.jsx index bedb1be..5b430b0 100644 --- a/src/components/Dashboard/Sales/SalesOrders/ConfirmSalesOrder.jsx +++ b/src/components/Dashboard/Sales/SalesOrders/ConfirmSalesOrder.jsx @@ -44,4 +44,3 @@ ConfirmSalesOrder.propTypes = { } export default ConfirmSalesOrder - diff --git a/src/components/Dashboard/Sales/SalesOrders/PostSalesOrder.jsx b/src/components/Dashboard/Sales/SalesOrders/PostSalesOrder.jsx index d906ddb..00ef6a2 100644 --- a/src/components/Dashboard/Sales/SalesOrders/PostSalesOrder.jsx +++ b/src/components/Dashboard/Sales/SalesOrders/PostSalesOrder.jsx @@ -44,4 +44,3 @@ PostSalesOrder.propTypes = { } export default PostSalesOrder - diff --git a/src/components/Dashboard/Sales/SalesSidebar.jsx b/src/components/Dashboard/Sales/SalesSidebar.jsx index da7dd8d..b0ef781 100644 --- a/src/components/Dashboard/Sales/SalesSidebar.jsx +++ b/src/components/Dashboard/Sales/SalesSidebar.jsx @@ -4,6 +4,7 @@ import ClientIcon from '../../Icons/ClientIcon' import SalesIcon from '../../Icons/SalesIcon' import SalesOrderIcon from '../../Icons/SalesOrderIcon' import MarketplaceIcon from '../../Icons/MarketplaceIcon' +import ListingIcon from '../../Icons/ListingIcon' const items = [ { @@ -30,6 +31,12 @@ const items = [ label: 'Marketplaces', icon: , path: '/dashboard/sales/marketplaces' + }, + { + key: 'listings', + label: 'Listings', + icon: , + path: '/dashboard/sales/listings' } ] @@ -37,7 +44,9 @@ const routeKeyMap = { '/dashboard/sales/overview': 'overview', '/dashboard/sales/clients': 'clients', '/dashboard/sales/salesorders': 'salesorders', - '/dashboard/sales/marketplaces': 'marketplaces' + '/dashboard/sales/marketplaces': 'marketplaces', + '/dashboard/sales/listings': 'listings', + '/dashboard/sales/listingvarients': 'listings' } const SalesSidebar = (props) => { diff --git a/src/components/Dashboard/common/ObjectProperty.jsx b/src/components/Dashboard/common/ObjectProperty.jsx index 041a197..89a2951 100644 --- a/src/components/Dashboard/common/ObjectProperty.jsx +++ b/src/components/Dashboard/common/ObjectProperty.jsx @@ -143,6 +143,10 @@ const ObjectProperty = ({ if (name?.includes('.')) { formItemName = name ? name.split('.') : undefined } + // For state type with options (editable), form field is state.type + if (type === 'state' && options?.length && !readOnly) { + formItemName = ['state', 'type'] + } var textParams = { style: { whiteSpace: 'nowrap', minWidth: '0' } } @@ -662,6 +666,18 @@ const ObjectProperty = ({ {...inputProps} /> ) + case 'state': + if (options?.length && !readOnly) { + return ( + + ) + } + return null case 'priceMode': return (