Added better listing and listing varient support.

This commit is contained in:
Tom Butcher 2026-03-21 21:39:03 +00:00
parent 8e393e229f
commit 17da8a4407
34 changed files with 2545 additions and 89 deletions

View File

@ -1,16 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.58577,0,0,0.58577,29.000019,28.999974)">
<path d="M11.031,59.75L48.719,59.75C55.859,59.75 59.75,55.891 59.75,48.797L59.75,10.969C59.75,3.891 55.859,-0 48.719,-0L11.031,-0C3.906,-0 -0,3.891 -0,10.969L-0,48.797C-0,55.891 3.906,59.75 11.031,59.75ZM11.906,51.688C9.391,51.688 8.063,50.469 8.063,47.813L8.063,11.969C8.063,9.313 9.391,8.078 11.906,8.078L47.844,8.078C50.344,8.078 51.688,9.313 51.688,11.969L51.688,47.813C51.688,50.469 50.344,51.688 47.844,51.688L11.906,51.688Z" style="fill-rule:nonzero;"/>
<g transform="matrix(0.570497,0,0,0.570497,16.522802,13.725853)">
<path d="M6.499,56.236L41.124,56.236C44.294,56.236 46.809,54.281 46.809,50.825C46.809,47.443 44.391,45.464 41.124,45.464L18.117,45.464L18.117,45.062C22.049,43.001 23.774,38.335 23.774,33.596C23.774,32.751 23.669,32.057 23.534,31.399L37.509,31.399C39.584,31.399 41.06,30.032 41.06,28.125C41.06,26.241 39.584,24.905 37.509,24.905L22.134,24.905C21.788,23.604 21.265,21.585 21.265,19.395C21.265,13.243 26.441,10.758 32.681,10.758C34.774,10.758 36.433,10.942 37.882,11.247C38.957,11.394 40.282,11.543 41.544,11.543C44.007,11.543 46.13,10.334 46.13,7.354C46.13,5.297 45.211,3.871 43.447,2.745C39.934,0.628 34.458,0.378 30.367,0.378C18.274,0.378 7.903,5.899 7.903,17.414C7.903,19.396 8.212,21.38 9.112,24.905L3.574,24.905C1.5,24.905 0,26.241 0,28.125C0,30.071 1.514,31.399 3.574,31.399L10.467,31.399C10.73,32.486 10.797,33.332 10.797,34.157C10.797,38.814 8.74,42.769 5.065,44.806C3.057,46.118 0.808,47.931 0.808,50.875C0.808,54.298 3.219,56.236 6.499,56.236Z" style="fill-rule:nonzero;"/>
</g>
</g>
<g transform="matrix(1,0,0,1,11.133834,5.394156)">
<rect x="0" y="0" width="74.451" height="58.962" style="fill-opacity:0;"/>
<g transform="matrix(0.700206,0,0,0.700206,-5.133834,5.956054)">
<path d="M28.563,58.962L10.588,58.962C3.679,58.962 0,55.326 0,48.481L0,10.522C0,3.667 3.679,0.02 10.588,0.02L63.676,0.02C70.606,0.02 74.264,3.667 74.264,10.522L74.264,20.933C74.047,20.926 73.827,20.922 73.604,20.922L67.305,20.922L67.305,11.251C67.305,8.378 65.813,6.979 63.097,6.979L11.167,6.979C8.429,6.979 6.959,8.378 6.959,11.251L6.959,47.743C6.959,50.615 8.429,52.003 11.167,52.003L28.563,52.003L28.563,58.962ZM28.857,31.097L16.175,31.097C14.981,31.097 14.107,30.204 14.107,29.051C14.107,27.929 14.981,27.055 16.175,27.055L30.296,27.055C29.634,28.235 29.144,29.582 28.857,31.097ZM16.175,19.207C14.981,19.207 14.107,18.313 14.107,17.138C14.107,16.016 14.981,15.164 16.175,15.164L58.13,15.164C59.295,15.164 60.157,16.016 60.157,17.138C60.157,18.313 59.295,19.207 58.13,19.207L16.175,19.207Z"/>
<g>
<path d="M12.892,61L51.107,61C57.723,61 60.999,57.755 60.999,51.265L60.999,12.766C60.999,6.276 57.723,3 51.107,3L12.892,3C6.308,3 3,6.276 3,12.766L3,51.265C3,57.755 6.308,61 12.892,61ZM12.955,55.928C9.805,55.928 8.072,54.258 8.072,50.982L8.072,13.05C8.072,9.774 9.805,8.072 12.955,8.072L51.044,8.072C54.163,8.072 55.927,9.773 55.927,13.05L55.927,50.982C55.927,54.258 54.163,55.928 51.044,55.928L12.955,55.928Z" style="fill-rule:nonzero;"/>
<g transform="matrix(0.630094,0,0,0.630094,12.050043,9.166267)">
<path d="M14.688,30.601L14.688,17.094C14.688,15.264 15.627,13.647 17.191,12.746L29.089,5.879C30.674,4.944 32.523,4.944 34.111,5.878L46.001,12.745C47.566,13.647 48.504,15.264 48.504,17.094L48.504,30.521L60.25,37.305C61.815,38.206 62.754,39.824 62.754,41.653L62.754,55.39C62.754,57.22 61.813,58.84 60.251,59.729L48.363,66.604C46.769,67.533 44.919,67.532 43.339,66.605L31.662,59.857L19.995,66.604C18.401,67.533 16.552,67.532 14.971,66.605L3.077,59.732C1.511,58.84 0.57,57.22 0.57,55.39L0.57,41.653C0.57,39.824 1.509,38.206 3.073,37.306L14.688,30.601ZM22.461,16.011L31.554,21.217L40.687,15.981L32,10.966L31.986,10.958C31.74,10.809 31.453,10.809 31.207,10.958L31.193,10.966L22.461,16.011ZM28.869,35.565L28.869,25.991L20.135,20.989L20.135,30.103C20.135,30.312 20.215,30.501 20.356,30.648L28.869,35.565ZM43.118,60.173L43.118,50.551L34.386,45.55L34.386,54.723C34.406,54.993 34.565,55.222 34.804,55.364L43.118,60.173ZM20.205,60.177L28.527,55.364C28.782,55.213 28.931,54.957 28.931,54.662L28.931,45.503L20.205,50.498L20.205,60.177ZM14.751,60.174L14.751,50.551L6.017,45.549L6.017,54.662C6.017,54.958 6.179,55.212 6.437,55.364L14.751,60.174ZM34.324,35.617L42.645,30.805C42.901,30.653 43.05,30.398 43.05,30.103L43.05,20.943L34.324,25.938L34.324,35.617ZM36.709,40.57L45.803,45.777L54.936,40.54L46.249,35.526L46.234,35.517C45.989,35.368 45.702,35.368 45.457,35.517L45.442,35.526L36.709,40.57ZM48.573,60.177L56.895,55.364C57.15,55.213 57.299,54.957 57.299,54.662L57.299,45.503L48.573,50.498L48.573,60.177ZM8.341,40.57L17.436,45.777L26.526,40.564L17.621,35.418L17.618,35.417C17.44,35.381 17.256,35.417 17.09,35.517L17.075,35.526L8.341,40.57Z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g>
<path d="M34.148,61L12.892,61C6.308,61 3,57.755 3,51.265L3,12.766C3,6.276 6.308,3 12.892,3L51.107,3C57.723,3 60.999,6.276 60.999,12.766L60.999,34.148C59.513,32.753 57.802,31.593 55.927,30.73L55.927,13.05C55.927,9.773 54.163,8.072 51.044,8.072L12.955,8.072C9.805,8.072 8.072,9.774 8.072,13.05L8.072,50.982C8.072,54.258 9.805,55.928 12.955,55.928L30.73,55.928C31.593,57.803 32.752,59.514 34.148,61ZM29.009,48.611L24.649,51.133C23.644,51.718 22.48,51.718 21.483,51.134L13.989,46.803C13.002,46.241 12.409,45.22 12.409,44.067L12.409,35.412C12.409,34.259 13.001,33.24 13.987,32.672L21.305,28.448L21.305,19.937C21.305,18.784 21.897,17.765 22.882,17.197L30.379,12.87C31.377,12.281 32.542,12.281 33.543,12.87L41.035,17.197C42.021,17.765 42.612,18.784 42.612,19.937L42.612,28.397L44.285,29.364C37.862,30.639 32.6,35.156 30.28,41.135L30.28,37.837L24.781,40.985L24.781,47.084L29.331,44.452C29.113,45.602 28.999,46.788 28.999,48C28.999,48.205 29.003,48.408 29.009,48.611ZM33.677,31.608L38.92,28.576C39.082,28.481 39.176,28.32 39.176,28.134L39.176,22.362L33.677,25.51L33.677,31.608ZM26.202,19.254L31.932,22.535L37.687,19.236L32.213,16.076L32.204,16.071C32.049,15.977 31.868,15.977 31.713,16.071L31.705,16.076L26.202,19.254ZM17.306,34.729L23.036,38.01L28.764,34.726L23.153,31.483L23.151,31.483C23.039,31.46 22.923,31.482 22.818,31.546L22.809,31.551L17.306,34.729ZM30.241,31.576L30.241,25.543L24.737,22.391L24.737,28.134C24.737,28.266 24.788,28.384 24.876,28.477L30.241,31.576ZM21.345,47.081L21.345,41.018L15.841,37.866L15.841,43.608C15.841,43.795 15.944,43.955 16.106,44.051L21.345,47.081Z"/>
<g transform="matrix(1.077085,0,0,1.077085,-2.466684,-4.933344)">
<path d="M46.854,34.29C55.053,34.29 61.709,40.946 61.709,49.145C61.709,57.344 55.053,64 46.854,64C38.656,64 32,57.344 32,49.145C32,40.946 38.656,34.29 46.854,34.29ZM46.854,38.468C40.962,38.468 36.177,43.252 36.177,49.145C36.177,55.038 40.962,59.822 46.854,59.822C52.747,59.822 57.531,55.038 57.531,49.145C57.531,43.252 52.747,38.468 46.854,38.468Z"/>
</g>
<g transform="matrix(1,0,0,1,-1.649781,-7.506927)">
<circle cx="49.627" cy="50.064" r="3.017"/>
</g>
<g transform="matrix(1,0,0,1,-6.45365,0.751087)">
<circle cx="49.627" cy="50.064" r="3.017"/>
</g>
<g transform="matrix(1,0,0,1,3.071774,0.733775)">
<circle cx="49.627" cy="50.064" r="3.017"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -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={<AuthCallback />}
/>
<Route
path='/auth/marketplace/callback'
element={<MarketplaceAuthCallback />}
/>
<Route
path='/email/notification'
element={<EmailNotificationTemplate />}

View File

@ -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 (
<>
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='middle'>
<Space size='small'>
<ObjectActions
type='listingVarient'
id={listingVarientId}
disabled={objectFormState.loading}
objectData={objectFormState.objectData}
/>
<ViewButton
disabled={objectFormState.loading}
items={[
{ key: 'info', label: 'Listing Varient Information' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]}
visibleState={collapseState}
updateVisibleState={updateCollapseState}
/>
<UserNotifierToggle
type='listingVarient'
objectData={objectFormState.objectData}
disabled={objectFormState.loading}
/>
<DocumentPrintButton
type='listingVarient'
objectData={objectFormState.objectData}
disabled={objectFormState.loading}
/>
</Space>
<LockIndicator lock={objectFormState.lock} />
</Space>
<Space>
<EditButtons
isEditing={objectFormState.isEditing}
handleUpdate={() => {
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}
/>
</Space>
</Flex>
<ScrollBox>
<Flex vertical gap={'large'}>
<ActionHandler
actions={actions}
loading={objectFormState.loading}
ref={actionHandlerRef}
>
<ObjectForm
id={listingVarientId}
type='listingVarient'
style={{ height: '100%' }}
ref={objectFormRef}
onStateChange={(state) => {
setEditFormState((prev) => ({ ...prev, ...state }))
}}
>
{({ loading, isEditing, objectData }) => (
<Flex vertical gap={'large'}>
<InfoCollapse
title='Listing Varient Information'
icon={<InfoCircleIcon />}
active={collapseState.info}
onToggle={(expanded) =>
updateCollapseState('info', expanded)
}
collapseKey='info'
>
<ObjectInfo
loading={loading}
isEditing={isEditing}
type='listingVarient'
objectData={objectData}
/>
</InfoCollapse>
</Flex>
)}
</ObjectForm>
</ActionHandler>
<InfoCollapse
title='Notes'
icon={<NoteIcon />}
active={collapseState.notes}
onToggle={(expanded) => updateCollapseState('notes', expanded)}
collapseKey='notes'
>
<Card>
<NotesPanel _id={listingVarientId} type='listingVarient' />
</Card>
</InfoCollapse>
<InfoCollapse
title='Audit Logs'
icon={<AuditLogIcon />}
active={collapseState.auditLogs}
onToggle={(expanded) =>
updateCollapseState('auditLogs', expanded)
}
collapseKey='auditLogs'
>
{objectFormState.loading ? (
<InfoCollapsePlaceholder />
) : (
<ObjectTable
type='auditLog'
masterFilter={{ 'parent._id': listingVarientId }}
visibleColumns={{ _id: false, 'parent._id': false }}
/>
)}
</InfoCollapse>
</Flex>
</ScrollBox>
</Flex>
<Modal
open={publishOpen}
onCancel={() => setPublishOpen(false)}
width={515}
footer={null}
destroyOnHidden={true}
centered={true}
>
<PublishListingVarient
objectData={objectFormState.objectData}
onOk={() => {
setPublishOpen(false)
actions.reload()
}}
/>
</Modal>
<Modal
open={unpublishOpen}
onCancel={() => setUnpublishOpen(false)}
width={515}
footer={null}
destroyOnHidden={true}
centered={true}
>
<UnpublishListingVarient
objectData={objectFormState.objectData}
onOk={() => {
setUnpublishOpen(false)
actions.reload()
}}
/>
</Modal>
</>
)
}
export default ListingVarientInfo

View File

@ -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 (
<NewObjectForm
type={'listingVarient'}
defaultValues={{ status: 'draft', ...defaultValues }}
>
{({ handleSubmit, submitLoading, objectData, formValid }) => {
const steps = [
{
title: 'Required',
key: 'required',
content: (
<ObjectInfo
type='listingVarient'
column={1}
bordered={false}
isEditing={true}
required={true}
objectData={objectData}
/>
)
},
{
title: 'Optional',
key: 'optional',
content: (
<ObjectInfo
type='listingVarient'
column={1}
bordered={false}
isEditing={true}
required={false}
objectData={objectData}
/>
)
},
{
title: 'Summary',
key: 'summary',
content: (
<ObjectInfo
type='listingVarient'
column={1}
bordered={false}
visibleProperties={{
_id: false,
createdAt: false,
updatedAt: false,
lastSyncedAt: false
}}
isEditing={false}
objectData={objectData}
/>
)
}
]
return (
<WizardView
steps={steps}
loading={submitLoading}
formValid={formValid}
title='New Listing Varient'
onSubmit={async () => {
const result = await handleSubmit()
if (result) {
onOk()
}
}}
/>
)
}}
</NewObjectForm>
)
}
NewListingVarient.propTypes = {
onOk: PropTypes.func.isRequired,
reset: PropTypes.bool,
defaultValues: PropTypes.object
}
export default NewListingVarient

View File

@ -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 (
<MessageDialogView
title='Publish this listing variant on the marketplace?'
description={`Publishes this SKU on the connected marketplace so the item goes live when a draft listing exists (eBay).${
ref ? ` (variant: ${ref})` : ''
}`}
onOk={handlePublish}
okText='Publish'
okLoading={loading}
/>
)
}
PublishListingVarient.propTypes = {
onOk: PropTypes.func.isRequired,
objectData: PropTypes.object
}
export default PublishListingVarient

View File

@ -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 (
<MessageDialogView
title="Withdraw this variant from the marketplace?"
description={`Ends the live marketplace listing for this SKU where applicable (eBay).${
ref ? ` (variant: ${ref})` : ''
}`}
onOk={handleUnpublish}
okText='Unpublish'
okLoading={loading}
/>
)
}
UnpublishListingVarient.propTypes = {
onOk: PropTypes.func.isRequired,
objectData: PropTypes.object
}
export default UnpublishListingVarient

View File

@ -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: <PlusIcon />
},
{ type: 'divider' },
{
label: 'Reload List',
key: 'reloadList',
icon: <ReloadIcon />
}
],
onClick: ({ key }) => {
if (key === 'reloadList') {
tableRef.current?.reload()
} else if (key === 'newListing') {
setNewListingOpen(true)
}
}
}
return (
<>
<Flex vertical={'true'} gap='large' className='h-100'>
<Flex justify={'space-between'}>
<Space size='small'>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
<ColumnViewButton
type='listing'
loading={false}
visibleState={columnVisibility}
updateVisibleState={setColumnVisibility}
/>
<ExportListButton objectType='listing' />
</Space>
<Space>
<FilterSidebarButton
active={showFilterSidebar}
onClick={() => setShowFilterSidebar(!showFilterSidebar)}
/>
<ObjectTableViewButton
viewMode={viewMode}
setViewMode={setViewMode}
/>
</Space>
</Flex>
<ObjectTable
ref={tableRef}
visibleColumns={columnVisibility}
type='listing'
cards={viewMode === 'cards'}
showFilterSidebar={showFilterSidebar}
/>
</Flex>
<Modal
open={newListingOpen}
styles={{ content: { paddingBottom: '24px' } }}
footer={null}
width={800}
onCancel={() => {
setNewListingOpen(false)
}}
destroyOnHidden={true}
>
<NewListing
onOk={() => {
setNewListingOpen(false)
tableRef.current?.reload()
}}
reset={newListingOpen}
/>
</Modal>
</>
)
}
export default Listings

View File

@ -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 (
<>
<Flex
gap='large'
vertical='true'
style={{ maxHeight: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='middle'>
<Space size='small'>
<ObjectActions
type='listing'
id={listingId}
disabled={objectFormState.loading}
objectData={objectFormState.objectData}
/>
<ViewButton
disabled={objectFormState.loading}
items={[
{ key: 'info', label: 'Listing Information' },
{ key: 'listingVarients', label: 'Listing Varients' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]}
visibleState={collapseState}
updateVisibleState={updateCollapseState}
/>
<UserNotifierToggle
type='listing'
objectData={objectFormState.objectData}
disabled={objectFormState.loading}
/>
<DocumentPrintButton
type='listing'
objectData={objectFormState.objectData}
disabled={objectFormState.loading}
/>
</Space>
<LockIndicator lock={objectFormState.lock} />
</Space>
<Space>
<EditButtons
isEditing={objectFormState.isEditing}
handleUpdate={() => {
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}
/>
</Space>
</Flex>
<ScrollBox>
<Flex vertical gap={'large'}>
<ActionHandler
actions={actions}
loading={objectFormState.loading}
ref={actionHandlerRef}
>
<ObjectForm
id={listingId}
type='listing'
style={{ height: '100%' }}
ref={objectFormRef}
onStateChange={(state) => {
setEditFormState((prev) => ({ ...prev, ...state }))
}}
>
{({ loading, isEditing, objectData }) => (
<Flex vertical gap={'large'}>
<InfoCollapse
title='Listing Information'
icon={<InfoCircleIcon />}
active={collapseState.info}
onToggle={(expanded) =>
updateCollapseState('info', expanded)
}
collapseKey='info'
>
<ObjectInfo
loading={loading}
isEditing={isEditing}
type='listing'
objectData={objectData}
/>
</InfoCollapse>
<InfoCollapse
title='Listing Varients'
icon={<ListingVarientIcon />}
active={collapseState.listingVarients}
onToggle={(expanded) =>
updateCollapseState('listingVarients', expanded)
}
collapseKey='listingVarients'
>
<ObjectTable
type='listingVarient'
masterFilter={{
'listing._id': listingId
}}
visibleColumns={{ listing: false }}
ref={listingVarientsTableRef}
/>
</InfoCollapse>
</Flex>
)}
</ObjectForm>
</ActionHandler>
<InfoCollapse
title='Notes'
icon={<NoteIcon />}
active={collapseState.notes}
onToggle={(expanded) => updateCollapseState('notes', expanded)}
collapseKey='notes'
>
<Card>
<NotesPanel _id={listingId} type='listing' />
</Card>
</InfoCollapse>
<InfoCollapse
title='Audit Logs'
icon={<AuditLogIcon />}
active={collapseState.auditLogs}
onToggle={(expanded) =>
updateCollapseState('auditLogs', expanded)
}
collapseKey='auditLogs'
>
{objectFormState.loading ? (
<InfoCollapsePlaceholder />
) : (
<ObjectTable
type='auditLog'
masterFilter={{ 'parent._id': listingId }}
visibleColumns={{ _id: false, 'parent._id': false }}
/>
)}
</InfoCollapse>
</Flex>
</ScrollBox>
</Flex>
<Modal
open={newListingVarientOpen}
onCancel={() => {
setNewListingVarientOpen(false)
}}
width={800}
footer={null}
destroyOnHidden={true}
>
<NewListingVarient
onOk={() => {
setNewListingVarientOpen(false)
listingVarientsTableRef.current?.reload()
}}
reset={newListingVarientOpen}
defaultValues={{
listing: { _id: listingId }
}}
/>
</Modal>
<Modal
open={publishListingOpen}
onCancel={() => setPublishListingOpen(false)}
width={515}
footer={null}
destroyOnHidden={true}
centered={true}
>
<PublishListing
objectData={objectFormState.objectData}
onOk={() => {
setPublishListingOpen(false)
actions.reload()
listingVarientsTableRef.current?.reload?.()
}}
/>
</Modal>
<Modal
open={unpublishListingOpen}
onCancel={() => setUnpublishListingOpen(false)}
width={515}
footer={null}
destroyOnHidden={true}
centered={true}
>
<UnpublishListing
objectData={objectFormState.objectData}
onOk={() => {
setUnpublishListingOpen(false)
actions.reload()
listingVarientsTableRef.current?.reload?.()
}}
/>
</Modal>
</>
)
}
export default ListingInfo

View File

@ -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 (
<NewObjectForm
type={'listing'}
defaultValues={{ status: 'draft', ...defaultValues }}
>
{({ handleSubmit, submitLoading, objectData, formValid }) => {
const steps = [
{
title: 'Required',
key: 'required',
content: (
<ObjectInfo
type='listing'
column={1}
bordered={false}
isEditing={true}
required={true}
objectData={objectData}
/>
)
},
{
title: 'Optional',
key: 'optional',
content: (
<ObjectInfo
type='listing'
column={1}
bordered={false}
isEditing={true}
required={false}
objectData={objectData}
/>
)
},
{
title: 'Summary',
key: 'summary',
content: (
<ObjectInfo
type='listing'
column={1}
bordered={false}
visibleProperties={{
_id: false,
createdAt: false,
updatedAt: false,
lastSyncedAt: false
}}
isEditing={false}
objectData={objectData}
/>
)
}
]
return (
<WizardView
steps={steps}
loading={submitLoading}
formValid={formValid}
title='New Listing'
onSubmit={async () => {
const result = await handleSubmit()
if (result) {
onOk()
}
}}
/>
)
}}
</NewObjectForm>
)
}
NewListing.propTypes = {
onOk: PropTypes.func.isRequired,
reset: PropTypes.bool,
defaultValues: PropTypes.object
}
export default NewListing

View File

@ -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 (
<MessageDialogView
title='Publish this listing on the marketplace?'
description={`Each variant with a SKU will be published on the connected marketplace. Variants that are already active are skipped.${
ref ? ` (listing: ${ref})` : ''
}`}
onOk={handlePublish}
okText='Publish'
okLoading={loading}
/>
)
}
PublishListing.propTypes = {
onOk: PropTypes.func.isRequired,
objectData: PropTypes.object
}
export default PublishListing

View File

@ -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 (
<MessageDialogView
title='Unpublish all variants for this listing?'
description={`Ends live marketplace listings for every active variant. On eBay the item is withdrawn and can be published again later.${
ref ? ` (listing: ${ref})` : ''
}`}
onOk={handleUnpublish}
okText='Unpublish'
okLoading={loading}
/>
)
}
UnpublishListing.propTypes = {
onOk: PropTypes.func.isRequired,
objectData: PropTypes.object
}
export default UnpublishListing

View File

@ -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 (
<Flex vertical align='center'>
<Result
title='Marketplace Connection'
subTitle={
<Text>
Use this callback URL for TikTok Shop redirects and as the eBay
RuName accept URL target. Click Connect to authorize your
marketplace account.
</Text>
}
icon={<MarketplaceIcon />}
>
<Flex
vertical
gap='middle'
align='center'
style={{ minWidth: '395px' }}
>
<Flex justify='center'>
<Flex gap='small' align='center' justify='center'>
<CopyButton size='default' text={callbackUrl} />
<Text code style={{ fontSize: '14px', wordBreak: 'break-all' }}>
{callbackUrl}
</Text>
</Flex>
</Flex>
<Flex justify='center'>
<Button
type='primary'
onClick={onConnect}
loading={loading}
disabled={disabled}
key='connect'
>
{isConnected ? 'Reconnect' : 'Connect'}
</Button>
</Flex>
</Flex>
</Result>
</Flex>
)
}
ConfigureMarketplace.propTypes = {
onConnect: PropTypes.func,
isConnected: PropTypes.bool,
loading: PropTypes.bool,
disabled: PropTypes.bool
}
export default ConfigureMarketplace

View File

@ -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 (
<>
<AuthParticles />
<Flex
align='center'
justify='center'
vertical
style={{ height: '100vh' }}
gap='large'
>
<Card style={{ borderRadius: 20 }}>
<Flex vertical align='center'>
<FarmControlLogo style={{ fontSize: '500px', height: '40px' }} />
</Flex>
</Card>
{status != 'error' && status != 'success' && (
<Alert
message='Completing provider authorization...'
icon={<LoadingOutlined />}
type='info'
showIcon
/>
)}
{status === 'success' && (
<Alert
message={resultMessage}
icon={<CheckIcon />}
type='success'
showIcon
/>
)}
{status === 'error' && (
<Alert
message={resultMessage}
icon={<ExclamationOctagonIcon />}
type='error'
showIcon
/>
)}
<Flex gap='middle'>
<Button
icon={<ArrowLeftIcon />}
onClick={() => {
setRedirectLoading(true)
navigate(returnTo)
}}
loading={redirectLoading}
disabled={status === 'loading' || redirectLoading}
size='large'
></Button>
</Flex>
</Flex>
</>
)
}
export default MarketplaceAuthCallback

View File

@ -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 = () => {
</Flex>
</ScrollBox>
</Flex>
<Modal
open={syncListingsOpen}
onCancel={() => setSyncListingsOpen(false)}
width={500}
footer={null}
destroyOnClose
centered
>
<SyncListings
onOk={() => {
setSyncListingsOpen(false)
actions.reload()
}}
objectData={objectFormState.objectData}
/>
</Modal>
<Modal
open={syncOrdersOpen}
onCancel={() => setSyncOrdersOpen(false)}
width={500}
footer={null}
destroyOnClose
centered
>
<SyncOrders
onOk={() => {
setSyncOrdersOpen(false)
actions.reload()
}}
objectData={objectFormState.objectData}
/>
</Modal>
<Modal
open={configureModalOpen}
onCancel={() => setConfigureModalOpen(false)}
width={650}
footer={null}
destroyOnClose
centered
>
<ConfigureMarketplace
onConnect={() => {
startAuthorization()
setConfigureModalOpen(false)
}}
isConnected={
!!(
objectFormState.objectData?.config?.refreshToken ||
objectFormState.objectData?.config?.accessToken ||
objectFormState.objectData?.config?.shopCipher
)
}
loading={objectFormState.loading}
disabled={objectFormState.isEditing}
/>
</Modal>
</>
)
}

View File

@ -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 (
<NewObjectForm
type={'marketplace'}
defaultValues={{ active: true, ...defaultValues }}
defaultValues={{
active: true,
config: {
redirectUri: callbackUrl
},
...defaultValues
}}
>
{({ 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}

View File

@ -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 (
<MessageDialogView
title='Sync listings from marketplace?'
description={`This will sync product listings from ${objectData?.name || objectData?._reference || objectData?._id} to your local database.`}
onOk={handleSync}
okText='Sync'
okLoading={syncLoading}
/>
)
}
SyncListings.propTypes = {
onOk: PropTypes.func.isRequired,
objectData: PropTypes.object
}
export default SyncListings

View File

@ -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 (
<MessageDialogView
title='Sync orders from marketplace?'
description={`This will sync orders from ${objectData?.name || objectData?._reference || objectData?._id} to your local database.`}
onOk={handleSync}
okText='Sync'
okLoading={syncLoading}
/>
)
}
SyncOrders.propTypes = {
onOk: PropTypes.func.isRequired,
objectData: PropTypes.object
}
export default SyncOrders

View File

@ -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}`)
}

View File

@ -44,4 +44,3 @@ CancelSalesOrder.propTypes = {
}
export default CancelSalesOrder

View File

@ -44,4 +44,3 @@ ConfirmSalesOrder.propTypes = {
}
export default ConfirmSalesOrder

View File

@ -44,4 +44,3 @@ PostSalesOrder.propTypes = {
}
export default PostSalesOrder

View File

@ -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: <MarketplaceIcon />,
path: '/dashboard/sales/marketplaces'
},
{
key: 'listings',
label: 'Listings',
icon: <ListingIcon />,
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) => {

View File

@ -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 (
<CustomSelect
placeholder={'Select a ' + label.toLowerCase() + '...'}
options={Array.isArray(options) ? options : []}
{...inputProps}
{...(useFormItem ? {} : { value: value?.type })}
/>
)
}
return null
case 'priceMode':
return (
<Select

View File

@ -80,6 +80,30 @@ const StateTag = ({ state, showBadge = true, style = {} }) => {
status = 'default'
text = 'Draft'
break
case 'active':
status = 'success'
text = 'Active'
break
case 'inactive':
status = 'default'
text = 'Inactive'
break
case 'deleted':
status = 'error'
text = 'Deleted'
break
case 'suspended':
status = 'warning'
text = 'Suspended'
break
case 'syncing':
status = 'processing'
text = 'Syncing'
break
case 'disconnected':
status = 'default'
text = 'Disconnected'
break
case 'failed':
status = 'error'
text = 'Failed'

View File

@ -30,10 +30,7 @@ const UrlDisplay = ({ url, showCopy = true, showLink = false }) => {
</Link>
) : (
<>
<Text
style={{ marginRight: 8, minWidth: 0, flex: 1, width: 0 }}
ellipsis
>
<Text style={{ marginRight: 8, minWidth: 0 }} ellipsis>
{url}
</Text>
<Tooltip title='Open URL' arrow={false}>

View File

@ -939,6 +939,37 @@ const ApiServerProvider = ({ children }) => {
}
}
const getObjectFunction = async (id, type, functionName, params = {}) => {
const url = `${config.backendUrl}/${type.toLowerCase()}s/${id}/${functionName}`
logger.debug(`Fetching object function ${functionName} for ${id} at ${url}`)
try {
const response = await axios.get(url, {
params,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`
}
})
return response.data
} catch (err) {
console.error(err)
showError(err, () => {
getObjectFunction(id, type, functionName, params)
})
return {}
}
}
const getMarketplaceAuthUrl = async (marketplaceId, state) => {
return getObjectFunction(marketplaceId, 'Marketplace', 'auth/url', {
state
})
}
const refreshMarketplaceAuth = async (marketplaceId) => {
return sendObjectFunction(marketplaceId, 'Marketplace', 'auth/refresh')
}
// Export list data to CSV and download
const exportToCsv = async (objectType) => {
try {
@ -1551,6 +1582,7 @@ const ApiServerProvider = ({ children }) => {
updateObject,
updateMultipleObjects,
createObject,
getObjectFunction,
sendObjectFunction,
deleteObject,
subscribeToObjectUpdates,
@ -1590,7 +1622,9 @@ const ApiServerProvider = ({ children }) => {
deleteNotificationApi,
deleteAllNotificationsApi,
registerNotificationListener,
unregisterNotificationListener
unregisterNotificationListener,
getMarketplaceAuthUrl,
refreshMarketplaceAuth
}}
>
{contextHolder}

View File

@ -0,0 +1,6 @@
import Icon from '@ant-design/icons'
import CustomIconSvg from '../../../assets/icons/listingicon.svg?react'
const ListingIcon = (props) => <Icon component={CustomIconSvg} {...props} />
export default ListingIcon

View File

@ -0,0 +1,8 @@
import Icon from '@ant-design/icons'
import CustomIconSvg from '../../../assets/icons/listingvarienticon.svg?react'
const ListingVarientIcon = (props) => (
<Icon component={CustomIconSvg} {...props} />
)
export default ListingVarientIcon

View File

@ -40,6 +40,8 @@ import { Payment } from './models/Payment.js'
import { Client } from './models/Client.js'
import { SalesOrder } from './models/SalesOrder.js'
import { Marketplace } from './models/Marketplace.js'
import { Listing } from './models/Listing.js'
import { ListingVarient } from './models/ListingVarient.js'
import QuestionCircleIcon from '../components/Icons/QuestionCircleIcon'
export const objectModels = [
@ -84,7 +86,9 @@ export const objectModels = [
Payment,
Client,
SalesOrder,
Marketplace
Marketplace,
Listing,
ListingVarient
]
// Re-export individual models for direct access
@ -130,7 +134,9 @@ export {
Payment,
Client,
SalesOrder,
Marketplace
Marketplace,
Listing,
ListingVarient
}
export function getModelByName(name, ignoreCase = false) {

View File

@ -0,0 +1,241 @@
import ListingIcon from '../../components/Icons/ListingIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import EditIcon from '../../components/Icons/EditIcon'
import CheckIcon from '../../components/Icons/CheckIcon'
import XMarkIcon from '../../components/Icons/XMarkIcon'
import ReloadIcon from '../../components/Icons/ReloadIcon'
import BinIcon from '../../components/Icons/BinIcon'
import PlusIcon from '../../components/Icons/PlusIcon'
export const Listing = {
name: 'listing',
label: 'Listing',
prefix: 'LST',
icon: ListingIcon,
actions: [
{
name: 'info',
label: 'Info',
default: true,
row: true,
icon: InfoCircleIcon,
url: (_id) => `/dashboard/sales/listings/info?listingId=${_id}`
},
{
name: 'reload',
label: 'Reload',
icon: ReloadIcon,
url: (_id) =>
`/dashboard/sales/listings/info?listingId=${_id}&action=reload`
},
{
name: 'edit',
label: 'Edit',
row: true,
icon: EditIcon,
url: (_id) =>
`/dashboard/sales/listings/info?listingId=${_id}&action=edit`,
visible: (objectData) => {
return !(objectData?._isEditing && objectData?._isEditing == true)
}
},
{
name: 'finishEdit',
label: 'Save Edits',
icon: CheckIcon,
url: (_id) =>
`/dashboard/sales/listings/info?listingId=${_id}&action=finishEdit`,
visible: (objectData) => {
return objectData?._isEditing && objectData?._isEditing == true
}
},
{
name: 'cancelEdit',
label: 'Cancel Edits',
icon: XMarkIcon,
url: (_id) =>
`/dashboard/sales/listings/info?listingId=${_id}&action=cancelEdit`,
visible: (objectData) => {
return objectData?._isEditing && objectData?._isEditing == true
}
},
{ type: 'divider' },
{
name: 'newListingVarient',
label: 'New Listing Varient',
type: 'button',
icon: PlusIcon,
url: (_id) =>
`/dashboard/sales/listings/info?listingId=${_id}&action=newListingVarient`
},
{ type: 'divider' },
{
name: 'publish',
label: 'Publish',
type: 'button',
icon: CheckIcon,
url: (_id) =>
`/dashboard/sales/listings/info?listingId=${_id}&action=publish`,
visible: (objectData) =>
objectData?.state?.type === 'draft' ||
objectData?.state?.type === 'inactive',
disabled: (objectData) => objectData?.state?.type === 'syncing'
},
{
name: 'unpublish',
label: 'Unpublish',
type: 'button',
icon: XMarkIcon,
danger: true,
url: (_id) =>
`/dashboard/sales/listings/info?listingId=${_id}&action=unpublish`,
visible: (objectData) => objectData?.state?.type === 'active',
disabled: (objectData) => objectData?.state?.type === 'syncing'
},
{ type: 'divider' },
{
name: 'delete',
label: 'Delete',
icon: BinIcon,
danger: true,
url: (_id) =>
`/dashboard/sales/listings/info?listingId=${_id}&action=delete`
}
],
columns: [
'_reference',
'title',
'product',
'marketplace',
'state',
'price',
'currency',
'lastSyncedAt',
'createdAt',
'updatedAt'
],
filters: [
'title',
'_id',
'product',
'marketplace',
'state',
'createdAt',
'updatedAt'
],
sorters: [
'title',
'state',
'price',
'lastSyncedAt',
'createdAt',
'updatedAt',
'_id'
],
group: ['marketplace', 'product'],
properties: [
{
name: '_id',
label: 'ID',
columnFixed: 'left',
type: 'id',
objectType: 'listing',
showCopy: true,
columnWidth: 140
},
{
name: 'createdAt',
label: 'Created At',
type: 'dateTime',
readOnly: true,
columnWidth: 175
},
{
name: '_reference',
label: 'Reference',
type: 'reference',
columnFixed: 'left',
objectType: 'listing',
showCopy: true,
readOnly: true
},
{
name: 'updatedAt',
label: 'Updated At',
type: 'dateTime',
readOnly: true,
columnWidth: 175
},
{
name: 'title',
label: 'Title',
columnFixed: 'left',
type: 'text',
readOnly: false,
required: false,
columnWidth: 250
},
{
name: 'lastSyncedAt',
label: 'Last Synced',
type: 'dateTime',
readOnly: true,
columnWidth: 175
},
{
name: 'product',
label: 'Product',
type: 'object',
objectType: 'product',
showHyperlink: true,
readOnly: false,
required: false,
columnWidth: 200
},
{
name: 'marketplace',
label: 'Marketplace',
type: 'object',
objectType: 'marketplace',
showHyperlink: true,
readOnly: false,
required: true,
columnWidth: 200
},
{
name: 'state',
label: 'State',
type: 'state',
objectType: 'listing',
readOnly: true,
columnWidth: 130
},
{
name: 'price',
label: 'Price',
type: 'number',
prefix: '£',
min: 0,
step: 0.01,
readOnly: true,
required: false,
columnWidth: 120
},
{
name: 'currency',
label: 'Currency',
type: 'text',
readOnly: true,
required: false,
columnWidth: 100
},
{
name: 'url',
label: 'URL',
type: 'url',
readOnly: true,
required: false,
columnWidth: 250
}
]
}

View File

@ -0,0 +1,243 @@
import ListingVarientIcon from '../../components/Icons/ListingVarientIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import EditIcon from '../../components/Icons/EditIcon'
import CheckIcon from '../../components/Icons/CheckIcon'
import XMarkIcon from '../../components/Icons/XMarkIcon'
import ReloadIcon from '../../components/Icons/ReloadIcon'
import BinIcon from '../../components/Icons/BinIcon'
export const ListingVarient = {
name: 'listingVarient',
label: 'Listing Varient',
prefix: 'LVR',
icon: ListingVarientIcon,
actions: [
{
name: 'info',
label: 'Info',
default: true,
row: true,
icon: InfoCircleIcon,
url: (_id) => `/dashboard/sales/listingvarients/info?listingVarientId=${_id}`
},
{
name: 'reload',
label: 'Reload',
icon: ReloadIcon,
url: (_id) =>
`/dashboard/sales/listingvarients/info?listingVarientId=${_id}&action=reload`
},
{
name: 'edit',
label: 'Edit',
row: true,
icon: EditIcon,
url: (_id) =>
`/dashboard/sales/listingvarients/info?listingVarientId=${_id}&action=edit`,
visible: (objectData) => {
return !(objectData?._isEditing && objectData?._isEditing == true)
}
},
{
name: 'finishEdit',
label: 'Save Edits',
icon: CheckIcon,
url: (_id) =>
`/dashboard/sales/listingvarients/info?listingVarientId=${_id}&action=finishEdit`,
visible: (objectData) => {
return objectData?._isEditing && objectData?._isEditing == true
}
},
{
name: 'cancelEdit',
label: 'Cancel Edits',
icon: XMarkIcon,
url: (_id) =>
`/dashboard/sales/listingvarients/info?listingVarientId=${_id}&action=cancelEdit`,
visible: (objectData) => {
return objectData?._isEditing && objectData?._isEditing == true
}
},
{ type: 'divider' },
{
name: 'publish',
label: 'Publish',
type: 'button',
icon: CheckIcon,
url: (_id) =>
`/dashboard/sales/listingvarients/info?listingVarientId=${_id}&action=publish`,
visible: (objectData) =>
objectData?.state?.type === 'draft' ||
objectData?.state?.type === 'inactive',
disabled: (objectData) => objectData?.state?.type === 'syncing'
},
{
name: 'unpublish',
label: 'Unpublish',
type: 'button',
icon: XMarkIcon,
danger: true,
url: (_id) =>
`/dashboard/sales/listingvarients/info?listingVarientId=${_id}&action=unpublish`,
visible: (objectData) => objectData?.state?.type === 'active',
disabled: (objectData) => objectData?.state?.type === 'syncing'
},
{ type: 'divider' },
{
name: 'delete',
label: 'Delete',
icon: BinIcon,
danger: true,
url: (_id) =>
`/dashboard/sales/listingvarients/info?listingVarientId=${_id}&action=delete`
}
],
columns: [
'_reference',
'listing',
'product',
'productSku',
'state',
'price',
'currency',
'lastSyncedAt',
'createdAt',
'updatedAt'
],
filters: [
'_id',
'listing',
'product',
'productSku',
'state',
'createdAt',
'updatedAt'
],
sorters: [
'state',
'price',
'lastSyncedAt',
'createdAt',
'updatedAt',
'_id'
],
group: ['listing', 'state'],
properties: [
{
name: '_id',
label: 'ID',
columnFixed: 'left',
type: 'id',
objectType: 'listingVarient',
showCopy: true,
columnWidth: 140
},
{
name: 'createdAt',
label: 'Created At',
type: 'dateTime',
readOnly: true,
columnWidth: 175
},
{
name: '_reference',
label: 'Reference',
type: 'reference',
columnFixed: 'left',
objectType: 'listingVarient',
showCopy: true,
readOnly: true
},
{
name: 'updatedAt',
label: 'Updated At',
type: 'dateTime',
readOnly: true,
columnWidth: 175
},
{
name: 'listing',
label: 'Listing',
type: 'object',
objectType: 'listing',
showHyperlink: true,
readOnly: false,
required: true,
columnWidth: 200
},
{
name: 'product',
label: 'Product',
type: 'object',
objectType: 'product',
showHyperlink: true,
readOnly: false,
required: false,
columnWidth: 200
},
{
name: 'productSku',
label: 'Product SKU',
type: 'object',
objectType: 'productSku',
showHyperlink: true,
readOnly: false,
required: false,
columnWidth: 200
},
{
name: 'state',
label: 'State',
type: 'state',
objectType: 'listingVarient',
readOnly: true,
columnWidth: 130
},
{
name: 'price',
label: 'Price',
type: 'number',
prefix: '£',
min: 0,
step: 0.01,
readOnly: false,
required: false,
columnWidth: 120
},
{
name: 'currency',
label: 'Currency',
type: 'text',
readOnly: false,
required: false,
columnWidth: 100
},
{
name: 'priceTaxRate',
label: 'Tax Rate',
type: 'object',
objectType: 'taxRate',
showHyperlink: true,
required: false,
columnWidth: 150
},
{
name: 'priceWithTax',
label: 'Price w/ Tax',
type: 'number',
prefix: '£',
min: 0,
step: 0.01,
readOnly: true,
required: false,
columnWidth: 130
},
{
name: 'lastSyncedAt',
label: 'Last Synced',
type: 'dateTime',
readOnly: true,
columnWidth: 175
}
]
}

View File

@ -4,6 +4,7 @@ import EditIcon from '../../components/Icons/EditIcon'
import CheckIcon from '../../components/Icons/CheckIcon'
import XMarkIcon from '../../components/Icons/XMarkIcon'
import ReloadIcon from '../../components/Icons/ReloadIcon'
import LinkIcon from '../../components/Icons/LinkIcon'
import BinIcon from '../../components/Icons/BinIcon'
export const Marketplace = {
@ -59,6 +60,67 @@ export const Marketplace = {
}
},
{ type: 'divider' },
{
name: 'syncListings',
label: 'Sync Listings',
type: 'button',
icon: ReloadIcon,
url: (_id) =>
`/dashboard/sales/marketplaces/info?marketplaceId=${_id}&action=syncListings`,
disabled: (objectData) => {
return objectData?.state?.type != 'ready'
}
},
{
name: 'syncOrders',
label: 'Sync Orders',
type: 'button',
icon: ReloadIcon,
url: (_id) =>
`/dashboard/sales/marketplaces/info?marketplaceId=${_id}&action=syncOrders`,
disabled: (objectData) => {
return objectData?.state?.type != 'ready'
}
},
{ type: 'divider' },
{
name: 'connect',
label: 'Connect',
type: 'button',
icon: LinkIcon,
url: (_id) =>
`/dashboard/sales/marketplaces/info?marketplaceId=${_id}&action=connect`,
disabled: (objectData) => {
return (
objectData?.state?.type == 'ready' ||
objectData?.state?.type == 'connecting' ||
objectData?.state?.type == 'syncing'
)
}
},
{
name: 'reconnect',
label: 'Reconnect',
type: 'button',
icon: LinkIcon,
url: (_id) =>
`/dashboard/sales/marketplaces/info?marketplaceId=${_id}&action=reconnect`,
disabled: (objectData) => {
return objectData?.state?.type != 'ready'
}
},
{
name: 'refreshToken',
label: 'Refresh Token',
type: 'button',
icon: ReloadIcon,
url: (_id) =>
`/dashboard/sales/marketplaces/info?marketplaceId=${_id}&action=refreshToken`,
disabled: (objectData) => {
return objectData?.state?.type != 'ready'
}
},
{ type: 'divider' },
{
name: 'delete',
label: 'Delete',
@ -72,12 +134,34 @@ export const Marketplace = {
'_reference',
'name',
'provider',
'authConnected',
'state',
'connected',
'config.accessTokenExpiresAt',
'active',
'createdAt',
'updatedAt'
],
filters: ['name', '_id', 'provider', 'active', 'createdAt', 'updatedAt'],
sorters: ['name', 'provider', 'active', 'createdAt', 'updatedAt', '_id'],
filters: [
'name',
'_id',
'provider',
'active',
'connected',
'state',
'createdAt',
'updatedAt'
],
sorters: [
'name',
'provider',
'active',
'connected',
'state',
'createdAt',
'updatedAt',
'_id'
],
group: ['provider'],
properties: [
{
@ -121,12 +205,12 @@ export const Marketplace = {
columnWidth: 200
},
{
name: 'active',
label: 'Active',
type: 'bool',
readOnly: false,
required: true,
columnWidth: 125
name: 'connectedAt',
label: 'Connected At',
type: 'dateTime',
readOnly: true,
showSince: true,
columnWidth: 175
},
{
name: 'provider',
@ -140,10 +224,67 @@ export const Marketplace = {
],
columnWidth: 150
},
{
name: 'config.accessTokenExpiresAt',
label: 'Access Token Expires At',
type: 'dateTime',
readOnly: true,
columnWidth: 215
},
{
name: 'config.appId',
label: 'App ID',
name: 'state',
label: 'State',
type: 'state',
objectType: 'marketplace',
readOnly: true,
columnWidth: 130
},
{
name: 'active',
label: 'Active',
type: 'bool',
readOnly: false,
required: true,
columnWidth: 125
},
{
name: 'connected',
label: 'Connected',
type: 'bool',
readOnly: true,
columnWidth: 125
},
{
name: 'config.refreshTokenExpiresAt',
label: 'Refresh Token Expires At',
type: 'dateTime',
readOnly: true,
showSince: true,
columnWidth: 200
},
{
name: 'config.clientId',
label: 'Client ID',
type: 'secret',
readOnly: false,
required: false,
columnWidth: 200,
visible: (objectData) => objectData?.provider === 'ebay'
},
{
name: 'config.lastTokenRefreshAt',
label: 'Last Token Refresh At',
type: 'dateTime',
readOnly: true,
showSince: true,
columnWidth: 200
},
{
name: 'config.clientSecret',
label: 'Client Secret',
type: 'secret',
readOnly: false,
required: false,
@ -151,49 +292,67 @@ export const Marketplace = {
visible: (objectData) => objectData?.provider === 'ebay'
},
{
name: 'config.certId',
label: 'Cert ID',
type: 'secret',
readOnly: false,
required: false,
columnWidth: 200,
visible: (objectData) => objectData?.provider === 'ebay'
},
{
name: 'config.devId',
label: 'Dev ID',
type: 'secret',
readOnly: false,
required: false,
columnWidth: 200,
visible: (objectData) => objectData?.provider === 'ebay'
},
{
name: 'config.userToken',
label: 'User Token',
type: 'secret',
readOnly: false,
required: false,
columnWidth: 200,
visible: (objectData) => objectData?.provider === 'ebay'
},
{
name: 'config.siteId',
label: 'Site ID',
name: 'config.ruName',
label: 'RuName',
type: 'text',
readOnly: false,
required: false,
columnWidth: 200,
visible: (objectData) => objectData?.provider === 'ebay'
},
{
name: 'config.marketplaceId',
label: 'Marketplace ID',
type: 'text',
readOnly: false,
required: false,
columnWidth: 160,
visible: (objectData) => objectData?.provider === 'ebay'
},
{
name: 'config.sandbox',
label: 'Sandbox',
type: 'bool',
readOnly: false,
required: false,
columnWidth: 120,
visible: (objectData) => objectData?.provider === 'ebay'
},
{
name: 'config.accessToken',
label: 'Access Token',
name: 'config.verificationToken',
label: 'Verification Token',
type: 'secret',
readOnly: false,
required: false,
columnWidth: 200,
visible: (objectData) => objectData?.provider === 'etsy'
visible: (objectData) => objectData?.provider === 'ebay'
},
{
name: 'config.refreshToken',
label: 'Refresh Token',
type: 'secret',
readOnly: true,
required: false,
columnWidth: 200,
visible: (objectData) => objectData?.provider === 'ebay'
},
{
name: 'config.accessToken',
label: 'Access Token',
type: 'secret',
readOnly: true,
required: false,
columnWidth: 200,
visible: (objectData) => objectData?.provider === 'ebay'
},
{
name: 'config.redirectUri',
label: 'Redirect URI',
type: 'url',
readOnly: false,
required: false,
columnWidth: 280,
visible: (objectData) => objectData?.provider === 'tiktokShop'
},
{
name: 'config.refreshToken',
@ -202,6 +361,12 @@ export const Marketplace = {
readOnly: false,
required: false,
columnWidth: 200,
value: (objectData) => {
if (objectData?.config?.refreshToken) {
return '••••••••••••••••••••••••••••••••••••'
}
return undefined
},
visible: (objectData) => objectData?.provider === 'etsy'
},
{
@ -213,6 +378,22 @@ export const Marketplace = {
columnWidth: 200,
visible: (objectData) => objectData?.provider === 'etsy'
},
{
name: 'config.accessToken',
label: 'Access Token',
type: 'secret',
readOnly: (objectData) => objectData?.provider === 'tiktokShop',
required: false,
columnWidth: 200,
value: (objectData) => {
if (objectData?.config?.accessToken) {
return '••••••••••••••••••••••••••••••••••••'
}
return undefined
},
visible: (objectData) =>
objectData?.provider === 'etsy' || objectData?.provider === 'tiktokShop'
},
{
name: 'config.appKey',
label: 'App Key',
@ -235,10 +416,45 @@ export const Marketplace = {
name: 'config.shopCipher',
label: 'Shop Cipher',
type: 'text',
readOnly: false,
readOnly: true,
required: false,
columnWidth: 200,
visible: (objectData) => objectData?.provider === 'tiktokShop'
},
{
name: 'config.shopName',
label: 'Shop Name',
type: 'text',
readOnly: true,
required: false,
columnWidth: 200,
visible: (objectData) => objectData?.provider === 'tiktokShop'
},
{
name: 'config.shopRegion',
label: 'Shop Region',
type: 'text',
readOnly: true,
required: false,
columnWidth: 140,
visible: (objectData) => objectData?.provider === 'tiktokShop'
},
{
name: 'config.callbackUrl',
label: 'Callback URL',
type: 'url',
readOnly: true,
required: false,
columnWidth: 280,
visible: (objectData) =>
objectData?.provider === 'ebay' ||
objectData?.provider === 'tiktokShop',
value: () => {
if (typeof window === 'undefined') {
return undefined
}
return `${window.location.origin}/auth/marketplace/callback`
}
}
]
}

View File

@ -1,9 +1,7 @@
import { lazy } from 'react'
import { Route } from 'react-router-dom'
const Clients = lazy(
() => import('../components/Dashboard/Sales/Clients.jsx')
)
const Clients = lazy(() => import('../components/Dashboard/Sales/Clients.jsx'))
const ClientInfo = lazy(
() => import('../components/Dashboard/Sales/Clients/ClientInfo.jsx')
)
@ -22,32 +20,56 @@ const Marketplaces = lazy(
const MarketplaceInfo = lazy(
() => import('../components/Dashboard/Sales/Marketplaces/MarketplaceInfo.jsx')
)
const Listings = lazy(
() => import('../components/Dashboard/Sales/Listings.jsx')
)
const ListingInfo = lazy(
() => import('../components/Dashboard/Sales/Listings/ListingInfo.jsx')
)
const ListingVarientInfo = lazy(
() =>
import('../components/Dashboard/Sales/ListingVarients/ListingVarientInfo.jsx')
)
const SalesRoutes = [
<Route
key='overview'
path='sales/overview'
element={<SalesOverview />}
/>,
<Route key='overview' path='sales/overview' element={<SalesOverview />} />,
<Route key='clients' path='sales/clients' element={<Clients />} />,
<Route
key='clients-info'
path='sales/clients/info'
element={<ClientInfo />}
/>,
<Route key='salesorders' path='sales/salesorders' element={<SalesOrders />} />,
<Route
key='salesorders'
path='sales/salesorders'
element={<SalesOrders />}
/>,
<Route
key='salesorders-info'
path='sales/salesorders/info'
element={<SalesOrderInfo />}
/>,
<Route key='marketplaces' path='sales/marketplaces' element={<Marketplaces />} />,
<Route
key='marketplaces'
path='sales/marketplaces'
element={<Marketplaces />}
/>,
<Route
key='marketplaces-info'
path='sales/marketplaces/info'
element={<MarketplaceInfo />}
/>,
<Route key='listings' path='sales/listings' element={<Listings />} />,
<Route
key='listings-info'
path='sales/listings/info'
element={<ListingInfo />}
/>,
<Route
key='listingvarients-info'
path='sales/listingvarients/info'
element={<ListingVarientInfo />}
/>
]
export default SalesRoutes