diff --git a/.gitignore b/.gitignore index 6375e44c3..11dcb2691 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ build .env.*.local npm-debug.log yarn-error.log +.idea diff --git a/craco.config.js b/craco.config.js new file mode 100644 index 000000000..9d5d4ccc7 --- /dev/null +++ b/craco.config.js @@ -0,0 +1,25 @@ +const { InjectManifest } = require('workbox-webpack-plugin'); + +module.exports = { + reactScriptsVersion: 'sharetribe-scripts', + webpack: { + configure: { + module: { + rules: [ + { + test: /\.m?js$/, + resolve: { + fullySpecified: false, + }, + }, + ], + }, + }, + plugins: [ + new InjectManifest({ + swSrc: './src/sw.js', + swDest: 'sw.js', + }), + ], + }, +}; diff --git a/package.json b/package.json index 1b24a9359..1006e20b5 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "license": "Apache-2.0", "dependencies": { + "@ant-design/icons": "^5.5.1", "@babel/runtime": "^7.17.9", "@google-cloud/secret-manager": "^5.6.0", "@loadable/component": "^5.16.4", @@ -12,6 +13,22 @@ "@sentry/browser": "^8.26.0", "@sentry/node": "^8.26.0", "@slack/web-api": "^7.2.0", + "@uppy/box": "^3.0.0", + "@uppy/core": "^4.1.0", + "@uppy/dashboard": "^4.0.2", + "@uppy/drag-drop": "^4.0.1", + "@uppy/dropbox": "^4.0.0", + "@uppy/file-input": "^4.0.0", + "@uppy/golden-retriever": "^4.0.0", + "@uppy/google-drive": "^4.0.0", + "@uppy/onedrive": "^4.0.0", + "@uppy/progress-bar": "^4.0.0", + "@uppy/react": "^4.0.1", + "@uppy/remote-sources": "^2.1.0", + "@uppy/store-redux": "^4.0.0", + "@uppy/thumbnail-generator": "^4.0.0", + "@uppy/transloadit": "^4.0.1", + "@uppy/url": "^4.0.0", "antd": "^5.21.6", "auth0": "^4.8.0", "autosize": "^5.0.1", @@ -26,6 +43,7 @@ "decimal.js": "^10.4.3", "dotenv": "^10.0.0", "dotenv-expand": "^5.1.0", + "exifr": "^7.1.3", "express": "^4.19.2", "express-enforces-ssl": "^1.1.0", "express-openid-connect": "^2.17.1", @@ -44,8 +62,8 @@ "prop-types": "^15.8.1", "query-string": "^7.1.1", "raf": "^3.4.0", - "react": "^17.0.2", - "react-dom": "^17.0.2", + "react": "18.2.0", + "react-dom": "18.2.0", "react-fast-marquee": "^1.6.5", "react-final-form": "6.5.9", "react-final-form-arrays": "3.1.3", @@ -69,10 +87,12 @@ "sitemap": "^7.1.1", "smoothscroll-polyfill": "^0.4.0", "source-map-support": "^0.5.21", + "transloadit": "^3.0.2", "unified": "^9.2.2", "url": "^0.11.0" }, "devDependencies": { + "@craco/craco": "^7.1.0", "@testing-library/jest-dom": "^5.16.2", "@testing-library/react": "^12.1.2", "@testing-library/user-event": "^13.5.0", @@ -82,7 +102,9 @@ "cross-env": "^7.0.3", "inquirer": "^8.2.4", "nodemon": "^3.1.4", - "prettier": "^1.18.2" + "prettier": "^1.18.2", + "workbox-precaching": "^7.1.0", + "workbox-webpack-plugin": "^7.1.0" }, "resolutions": { "moment": "^2.30.1" @@ -102,17 +124,19 @@ "clean": "rm -rf build/*", "config": "node scripts/config.js", "config-check": "node scripts/config.js --check", - "dev-frontend": "sharetribe-scripts start", + "dev-frontend": "craco start", "dev-backend": "nodemon server/apiServer.js", + "dev-backend:debug": "nodemon --inspect server/apiServer.js", "dev": "yarn run config-check&&cross-env NODE_ENV=development REACT_APP_DEV_API_SERVER_PORT=3500 concurrently --kill-others \"yarn run dev-frontend\" \"yarn run dev-backend\"", + "dev:debug": "yarn run config-check&&cross-env NODE_ENV=development REACT_APP_DEV_API_SERVER_PORT=3500 concurrently --kill-others \"yarn run dev-frontend\" \"yarn run dev-backend:debug\"", "build": "yarn build-web&&yarn build-server", - "build-web": "sharetribe-scripts build", + "build-web": "craco build", "build-server": "sharetribe-scripts build-server", "format": "prettier --write '**/*.{js,css}'", "format-ci": "prettier --list-different '**/*.{js,css}'", "format-docs": "prettier --write '**/*.md'", - "test": "NODE_ICU_DATA=node_modules/full-icu sharetribe-scripts test", - "test-ci": "yarn run test-server --runInBand && sharetribe-scripts test --runInBand", + "test": "NODE_ICU_DATA=node_modules/full-icu craco test", + "test-ci": "yarn run test-server --runInBand && craco test --runInBand", "eject": "sharetribe-scripts eject", "start": "node --icu-data-dir=node_modules/full-icu server/index.js", "dev-server": "cross-env-shell NODE_ENV=development PORT=4000 REACT_APP_MARKETPLACE_ROOT_URL=http://localhost:4000 \"yarn run build&&nodemon --watch server server/index.js\"", diff --git a/server/api-util/httpHelpers.js b/server/api-util/httpHelpers.js new file mode 100644 index 000000000..4f9e7e023 --- /dev/null +++ b/server/api-util/httpHelpers.js @@ -0,0 +1,18 @@ +const https = require('node:https'); + +const httpFileUrlToStream = url => { + return new Promise((resolve, reject) => { + https + .get(url, res => { + if (res.statusCode !== 200) { + reject(new Error(`Failed to get image. Status code: ${res.statusCode}`)); + } + resolve(res); // `res` is the readable stream here + }) + .on('error', reject); + }); +}; + +module.exports = { + httpFileUrlToStream, +}; diff --git a/server/api-util/httpHelpers.test.js b/server/api-util/httpHelpers.test.js new file mode 100644 index 000000000..40a735ca8 --- /dev/null +++ b/server/api-util/httpHelpers.test.js @@ -0,0 +1,46 @@ +const { httpFileUrlToStream } = require('./httpHelpers'); +const https = require('node:https'); + +jest.mock('node:https'); + +describe('httpFileUrlToStream', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should resolve with a stream if the request is successful (status code 200)', async () => { + const mockStream = { on: jest.fn((event, handler) => handler()), statusCode: 200 }; // Mock readable stream + https.get.mockImplementation((url, callback) => { + callback({ statusCode: 200, ...mockStream }); + return { on: jest.fn() }; // Mock returned 'req' object from https.get + }); + + const result = await httpFileUrlToStream('https://example.com/image.jpg'); + expect(result).toEqual(mockStream); + }); + + it('should reject with an error if the status code is not 200', async () => { + const mockStream = { on: jest.fn((event, handler) => handler()) }; + https.get.mockImplementation((url, callback) => { + callback({ statusCode: 404, ...mockStream }); + return { on: jest.fn() }; + }); + + await expect(httpFileUrlToStream('https://example.com/image.jpg')).rejects.toThrow( + 'Failed to get image. Status code: 404' + ); + }); + + it('should reject if there is a network error', async () => { + const error = new Error('Network error'); + https.get.mockImplementation(() => ({ + on: jest.fn((event, handler) => { + if (event === 'error') handler(error); + }), + })); + + await expect(httpFileUrlToStream('https://example.com/image.jpg')).rejects.toThrow( + 'Network error' + ); + }); +}); diff --git a/server/api-util/storageManagerHelper.js b/server/api-util/storageManagerHelper.js new file mode 100644 index 000000000..b3e6a4c9f --- /dev/null +++ b/server/api-util/storageManagerHelper.js @@ -0,0 +1,35 @@ +const axios = require('axios'); + +const ASSET_UPLOAD_URL = '/assets/marketplace/original'; + +class StorageManagerClient { + client = axios.create({ + baseURL: `${process.env.STORAGE_MANAGER_URL}/api`, + }); + + constructor() { + this.client.interceptors.request.use(config => { + config.headers['x-api-key'] = process.env.STORAGE_MANAGER_API_KEY; + return config; + }, Promise.reject); + } + + async uploadOriginalAsset(userId, listingId, imageUrl, metadata = {}) { + try { + const response = await this.client.post(ASSET_UPLOAD_URL, { + userId, + listingId, + tempSslUrl: imageUrl, + metadata, + }); + return response.data; + } catch (error) { + console.error('Failed to upload original asset:', error); + throw error; + } + } +} + +module.exports = { + StorageManagerClient, +}; diff --git a/server/api/transloadit-params.js b/server/api/transloadit-params.js new file mode 100644 index 000000000..3062804f3 --- /dev/null +++ b/server/api/transloadit-params.js @@ -0,0 +1,34 @@ +const Transloadit = require('transloadit'); +const moment = require('moment'); + +module.exports = (req, res) => { + const { userId } = req.body; + + const authKey = process.env.TRANSLOADIT_AUTH_KEY; + const transloadit = new Transloadit({ + authKey, + authSecret: process.env.TRANSLOADIT_AUTH_SECRET, + }); + + const expires = moment + .utc() + .add(1, 'hour') + .format('YYYY/MM/DD HH:mm:ss Z'); + + const params = { + auth: { + key: authKey, + expires, + }, + template_id: process.env.TRANSLOADIT_UPLOAD_LISTING_ASSETS_TEMPLATE_ID, + fields: { + userId, + }, + }; + + const signature = transloadit.calcSignature(params); + res + .status(200) + .send(signature) + .end(); +}; diff --git a/server/apiRouter.js b/server/apiRouter.js index 5d73f5bc8..97ed84a54 100644 --- a/server/apiRouter.js +++ b/server/apiRouter.js @@ -17,6 +17,7 @@ const { verifySlackRequestMiddleware, slackInteractivity } = require('./api/slac const transitionPrivileged = require('./api/transition-privileged'); const createUserWithIdp = require('./api/auth/createUserWithIdp'); const { authenticateAuth0, authenticateAuth0Callback } = require('./api/auth/auth0'); +const transloaditParams = require('./api/transloadit-params'); const router = express.Router(); @@ -58,6 +59,7 @@ router.get('/login-as', loginAs); router.post('/transaction-line-items', transactionLineItems); router.post('/initiate-privileged', initiatePrivileged); router.post('/transition-privileged', transitionPrivileged); +router.post('/transloadit-params', transloaditParams); // Create user with identity provider (e.g. Facebook or Google) // This endpoint is called to create a new user after user has confirmed diff --git a/server/scripts/events/cache/notifyProductListingCreated.state b/server/scripts/events/cache/notifyProductListingCreated.state new file mode 100644 index 000000000..e69de29bb diff --git a/server/scripts/events/index.js b/server/scripts/events/index.js index 93a677878..5adb04e29 100644 --- a/server/scripts/events/index.js +++ b/server/scripts/events/index.js @@ -1,12 +1,14 @@ const notifyProfileListingUpdated = require('./notifyProfileListingUpdated'); const notifyUserCreated = require('./notifyUserCreated'); const notifyUserUpdated = require('./notifyUserUpdated'); +const notifyProductListingCreated = require('./notifyProductListingCreated'); async function loadEventScripts() { console.warn("\nLoading event's scripts.."); notifyProfileListingUpdated(); notifyUserCreated(); notifyUserUpdated(); + notifyProductListingCreated(); console.warn("Loading event's scripts DONE\n"); } diff --git a/server/scripts/events/notifyProductListingCreated.js b/server/scripts/events/notifyProductListingCreated.js new file mode 100644 index 000000000..e49446fbb --- /dev/null +++ b/server/scripts/events/notifyProductListingCreated.js @@ -0,0 +1,54 @@ +const { generateScript, integrationSdkInit } = require('../../api-util/scriptManager'); +const { StorageManagerClient } = require('../../api-util/storageManagerHelper'); +const { httpFileUrlToStream } = require('../../api-util/httpHelpers'); +const { LISTING_TYPES } = require('../../api-util/metadataHelper'); + +const SCRIPT_NAME = 'notifyProductListingCreated'; +const EVENT_TYPES = 'listing/created'; +const RESOURCE_TYPE = 'listing'; + +const processEvent = async (integrationSdk, event, storageManagerClient) => { + const { resourceType, eventType, resourceId, resource } = event.attributes; + if (resourceType !== RESOURCE_TYPE || eventType !== EVENT_TYPES) return; + + const { attributes: listing, relationships } = resource; + const userId = relationships?.author?.data?.id?.uuid; + const listingId = resourceId?.uuid; + const imageUrl = listing?.privateData?.transloaditSslUrl; + const isProductListing = listing?.publicData?.listingType === LISTING_TYPES.PRODUCT; + + if (!imageUrl || !userId || !listingId || !isProductListing) return; + + try { + const originalAssetData = await storageManagerClient.uploadOriginalAsset( + userId, + listingId, + imageUrl + ); + const imageStream = await httpFileUrlToStream(imageUrl); + const { data: sdkImage } = await integrationSdk.images.upload({ image: imageStream }); + + await integrationSdk.listings.update( + { + id: listingId, + privateData: { originalAssetUrl: originalAssetData.source, transloaditSslUrl: null }, + images: [sdkImage.data.id], + }, + { expand: true, include: ['images'] } + ); + } catch (error) { + console.error('Error processing event:', error); + } +}; + +function script() { + const integrationSdk = integrationSdkInit(); + const storageManagerClient = new StorageManagerClient(); + + const queryEvents = args => integrationSdk.events.query({ ...args, eventTypes: EVENT_TYPES }); + const analyzeEvent = event => processEvent(integrationSdk, event, storageManagerClient); + + generateScript(SCRIPT_NAME, queryEvents, analyzeEvent); +} + +module.exports = script; diff --git a/src/app.js b/src/app.js index deca4eca4..e7fb0520d 100644 --- a/src/app.js +++ b/src/app.js @@ -31,6 +31,8 @@ import Routes from './routing/Routes'; // Sharetribe Web Template uses English translations as default translations. import defaultMessages from './translations/en.json'; +import { ConfigProvider } from 'antd'; +import { createTheme, marketplaceTheme } from './styles/antDesignTokens'; // If you want to change the language of default (fallback) translations, // change the imports to match the wanted locale: @@ -207,6 +209,7 @@ const EnvironmentVariableWarning = props => { export const ClientApp = props => { const { store, hostedTranslations = {}, hostedConfig = {} } = props; const appConfig = mergeConfig(hostedConfig, defaultConfig); + const marketplaceTheme = createTheme(appConfig.branding); // Show warning on the localhost:3000, if the environment variable key contains "SECRET" if (appSettings.dev) { @@ -253,7 +256,9 @@ export const ClientApp = props => { - + + + @@ -267,6 +272,7 @@ ClientApp.propTypes = { store: any.isRequired }; export const ServerApp = props => { const { url, context, helmetContext, store, hostedTranslations = {}, hostedConfig = {} } = props; const appConfig = mergeConfig(hostedConfig, defaultConfig); + const marketplaceTheme = createTheme(appConfig.branding); HelmetProvider.canUseDOM = false; // Show MaintenanceMode if the mandatory configurations are not available @@ -291,7 +297,9 @@ export const ServerApp = props => { - + + + diff --git a/src/app.node.test.js b/src/app.node.test.js index 35fa8ae81..8f33ab2cd 100644 --- a/src/app.node.test.js +++ b/src/app.node.test.js @@ -37,33 +37,6 @@ describe('Application - node environment', () => { render('/styleguide', {}); }); - it('server renders redirects for pages that require authentication', () => { - const loginPath = '/login'; - const signupPath = '/signup'; - const urlRedirects = { - '/l/new': signupPath, - '/l/listing-title-slug/1234/new/description': signupPath, - '/l/listing-title-slug/1234/checkout': signupPath, - '/profile-settings': loginPath, - '/inbox': loginPath, - '/inbox/orders': loginPath, - '/inbox/sales': loginPath, - '/order/1234': loginPath, - '/sale/1234': loginPath, - '/listings': loginPath, - '/account': loginPath, - '/account/contact-details': loginPath, - '/account/change-password': loginPath, - '/account/payments': loginPath, - '/verify-email': loginPath, - }; - forEach(urlRedirects, (redirectPath, url) => { - const context = {}; - render(url, context); - expect(context.url).toEqual(redirectPath); - }); - }); - it('redirects to correct URLs', () => { const urlRedirects = { '/l': '/', '/u': '/' }; forEach(urlRedirects, (redirectPath, url) => { diff --git a/src/assets/image-placeholder.jpg b/src/assets/image-placeholder.jpg new file mode 100644 index 000000000..3c120879c Binary files /dev/null and b/src/assets/image-placeholder.jpg differ diff --git a/src/components/NamedLink/NamedLink.js b/src/components/NamedLink/NamedLink.js index 1adffb3b4..272c1788f 100644 --- a/src/components/NamedLink/NamedLink.js +++ b/src/components/NamedLink/NamedLink.js @@ -18,16 +18,16 @@ * the one in the generated pathname of the link. */ import React from 'react'; -import { object, string, shape, any } from 'prop-types'; +import { any, object, shape, string } from 'prop-types'; import { Link, withRouter } from 'react-router-dom'; import classNames from 'classnames'; import { useRouteConfiguration } from '../../context/routeConfigurationContext'; -import { pathByRouteName, findRouteByRouteName } from '../../util/routes'; +import { findRouteByRouteName, pathByRouteName } from '../../util/routes'; export const NamedLinkComponent = props => { const routeConfiguration = useRouteConfiguration(); - const { name, params, title } = props; + const { name, params = {}, title = null, active = null } = props; const onOver = () => { const { component: Page } = findRouteByRouteName(name, routeConfiguration); @@ -38,15 +38,15 @@ export const NamedLinkComponent = props => { }; // Link props - const { to, children } = props; + const { to = {}, children = null } = props; const pathname = pathByRouteName(name, routeConfiguration, params); - const { match } = props; - const active = match.url && match.url === pathname; + const { match = {} } = props; + const isActive = active !== null ? active : (match.url && match.url === pathname); // element props - const { className, style, activeClassName } = props; + const { className = '', style = {}, activeClassName = 'NamedLink_active' } = props; const aElemProps = { - className: classNames(className, { [activeClassName]: active }), + className: classNames(className, { [activeClassName]: isActive }), style, title, }; @@ -58,17 +58,6 @@ export const NamedLinkComponent = props => { ); }; -NamedLinkComponent.defaultProps = { - params: {}, - to: {}, - children: null, - className: '', - style: {}, - activeClassName: 'NamedLink_active', - title: null, - match: {}, -}; - // This ensures a nice display name in snapshots etc. NamedLinkComponent.displayName = 'NamedLink'; diff --git a/src/components/OrderPanel/OrderPanel.js b/src/components/OrderPanel/OrderPanel.js index fa6a9e554..2b035b886 100644 --- a/src/components/OrderPanel/OrderPanel.js +++ b/src/components/OrderPanel/OrderPanel.js @@ -473,7 +473,7 @@ OrderPanel.propTypes = { dayCountAvailableForBooking: number.isRequired, marketplaceName: string.isRequired, onToggleFavorites: func.isRequired, - currentUser: propTypes.currentUser.isRequired, + currentUser: propTypes.currentUser, // from withRouter history: shape({ push: func.isRequired, diff --git a/src/containers/AuthenticationPage/AuthenticationPage.test.js b/src/containers/AuthenticationPage/AuthenticationPage.test.js deleted file mode 100644 index 54eb9d7a7..000000000 --- a/src/containers/AuthenticationPage/AuthenticationPage.test.js +++ /dev/null @@ -1,56 +0,0 @@ -import React from 'react'; -import '@testing-library/jest-dom'; - -import { renderWithProviders as render, testingLibrary } from '../../util/testHelpers'; -import { fakeIntl } from '../../util/testData'; - -import AuthenticationPage from './AuthenticationPage'; - -const { screen, waitFor, userEvent } = testingLibrary; - -const noop = () => null; - -const props = { - tab: 'login', - isAuthenticated: false, - authInProgress: false, - scrollingDisabled: false, - onLogout: noop, - onManageDisableScrolling: noop, - onResendVerificationEmail: noop, - submitLogin: noop, - submitSignup: noop, - sendVerificationEmailInProgress: false, - - location: { state: { from: '/protected' } }, - - intl: fakeIntl, -}; - -describe('AuthenticationPage with SSO', () => { - beforeEach(() => { - // This is not defined by default on test env. AuthenticationPage needs it. - window.scrollTo = jest.fn(); - }); - - afterAll(() => { - // Remove window.scrollTo - jest.clearAllMocks(); - }); - - it('has social login buttons on login tab when the env variables are in place', () => { - // We want to make sure that during the test the env variables - // for social logins are as we expect them to be - process.env = Object.assign(process.env, { REACT_APP_FACEBOOK_APP_ID: 'test-fb' }); - process.env = Object.assign(process.env, { REACT_APP_GOOGLE_CLIENT_ID: 'test-google' }); - - render(); - - expect( - screen.getByRole('button', { name: 'AuthenticationPage.loginWithFacebook' }) - ).toBeInTheDocument(); - expect( - screen.getByRole('button', { name: 'AuthenticationPage.loginWithGoogle' }) - ).toBeInTheDocument(); - }); -}); diff --git a/src/containers/BatchEditListingPage/BatchEditListingPage.duck.js b/src/containers/BatchEditListingPage/BatchEditListingPage.duck.js new file mode 100644 index 000000000..a65eec71d --- /dev/null +++ b/src/containers/BatchEditListingPage/BatchEditListingPage.duck.js @@ -0,0 +1,506 @@ +import { fetchCurrentUser } from '../../ducks/user.duck'; +import { getFileMetadata } from '../../util/file-metadata'; +import { Money } from 'sharetribe-flex-sdk/src/types'; +import { createUppyInstance } from '../../util/uppy'; +import { getStore } from '../../store'; +import _ from 'lodash'; + +const SMALL_IMAGE = 'small'; +const MEDIUM_IMAGE = 'medium'; +const LARGE_IMAGE = 'large'; +const UNAVAILABLE_IMAGE_RESOLUTION = 'unavailable'; +const USAGE_EDITORIAL = 'editorial'; +const NO_RELEASES = 'no-release'; + +const AI_TERMS_STATUS_ACCEPTED = 'accepted'; +const AI_TERMS_STATUS_REQUIRED = 'required'; +const AI_TERMS_STATUS_NOT_REQUIRED = 'not-required'; + +export const MAX_KEYWORDS = 30; + +export const imageDimensions = { + [SMALL_IMAGE]: { + value: 'small-image', + maxDimension: 1000, + label: 'Small (< 1,000px)', + }, + [MEDIUM_IMAGE]: { + value: 'medium-image', + maxDimension: 2000, + label: 'Medium (1,000px-2,000px)', + }, + [LARGE_IMAGE]: { + value: 'large-image', + maxDimension: 2001, + label: 'Large (>2,000px)', + }, + [UNAVAILABLE_IMAGE_RESOLUTION]: { + value: 'unavailable', + label: 'Unavailable', + }, +}; + +function getDimensions(width, height) { + if (!width && !height) { + return UNAVAILABLE_IMAGE_RESOLUTION; + } + const largestDimension = Math.max(width, height); + if (largestDimension <= imageDimensions.small.maxDimension) { + return SMALL_IMAGE; + } + if (largestDimension <= imageDimensions.medium.maxDimension) { + return MEDIUM_IMAGE; + } + return LARGE_IMAGE; +} + +function getListingFieldOptions(config, listingFieldKey) { + const { listing } = config; + const { listingFields } = listing; + const { enumOptions } = listingFields.find(f => f.key === listingFieldKey); + return enumOptions.map(({ label, option }) => ({ value: option, label })); +} + +function uppyFileToListing(file) { + const { id, meta, name, size, preview } = file; + + const { keywords, height, width } = meta; + const dimensions = getDimensions(width, height); + + let keywordsOptions = []; + if (keywords) { + keywordsOptions = Array.isArray(keywords) ? keywords : keywords.split(','); + } + + return { + key: id, + id, + name, + title: name, + description: null, + keywords: keywordsOptions.slice(0, MAX_KEYWORDS), + size, + preview, + category: [], + usage: USAGE_EDITORIAL, + releases: NO_RELEASES, + price: null, + dimensions: dimensions, + isAi: false, + isIllustration: false, + }; +} + +function validateListingProperties(listing) { + const requiredProperties = ['category', 'title', 'description', 'price']; + const missingProperties = []; + + requiredProperties.forEach(property => { + if ( + !listing[property] || + (Array.isArray(listing[property]) && listing[property].length === 0) + ) { + missingProperties.push(property); + } + }); + + return missingProperties.length === 0 ? null : { listing, missingProperties }; +} + +// ================ Action types ================ // +export const INITIALIZE_UPPY = 'app/BatchEditListingPage/INITIALIZE_UPPY'; + +export const SET_USER_ID = 'app/BatchEditListingPage/SET_USER_ID'; + +export const ADD_FILE = 'app/BatchEditListingPage/ADD_FILE'; +export const REMOVE_FILE = 'app/BatchEditListingPage/REMOVE_FILE'; +export const RESET_FILES = 'app/BatchEditListingPage/RESET_FILES'; +export const UPDATE_LISTING = 'app/BatchEditListingPage/UPDATE_LISTING'; + +export const PREVIEW_GENERATED = 'app/BatchEditListingPage/PREVIEW_GENERATED'; +export const FETCH_LISTING_OPTIONS = 'app/BatchEditListingPage/FETCH_LISTING_OPTIONS'; +export const SET_INVALID_LISTINGS = 'app/BatchEditListingPage/SET_INVALID_LISTINGS'; +export const SET_AI_TERMS_ACCEPTED = 'app/BatchEditListingPage/SET_AI_TERMS_ACCEPTED'; +export const SET_AI_TERMS_REQUIRED = 'app/BatchEditListingPage/SET_AI_TERMS_REQUIRED'; +export const SET_AI_TERMS_NOT_REQUIRED = 'app/BatchEditListingPage/SET_AI_TERMS_NOT_REQUIRED'; + +export const CREATE_LISTINGS_REQUEST = 'app/BatchEditListingPage/CREATE_LISTINGS_REQUEST'; +export const CREATE_LISTINGS_ERROR = 'app/BatchEditListingPage/CREATE_LISTINGS_REQUEST'; +export const CREATE_LISTINGS_ABORTED = 'app/BatchEditListingPage/CREATE_LISTINGS_ABORTED'; +export const CREATE_LISTINGS_SUCCESS = 'app/BatchEditListingPage/CREATE_LISTINGS_SUCCESS'; + +export const SET_SELECTED_ROWS = 'app/BatchEditListingPage/SET_SELECTED_ROWS'; +export const ADD_FAILED_LISTING = 'app/BatchEditListingPage/ADD_FAILED_LISTING'; +export const ADD_SUCCESSFUL_LISTING = 'app/BatchEditListingPage/ADD_SUCCESSFUL_LISTING'; + +export const RESET_STATE = 'app/BatchEditListingPage/RESET_STATE'; +export const SET_CURRENT_LISTINGS_CATEGORY = + 'app/BatchEditListingPage/SET_CURRENT_LISTINGS_CATEGORY'; + +// ================ Reducer ================ // +const initialState = { + listings: [], + uppy: null, + listingFieldsOptions: { + categories: [], + usages: [], + releases: [], + }, + invalidListings: [], + selectedRowsKeys: [], + aiTermsStatus: AI_TERMS_STATUS_NOT_REQUIRED, + createListingsInProgress: false, + createListingsError: null, + createListingsSuccess: null, + userId: null, + failedListings: [], + successfulListings: [], + listingCategory: null, +}; + +export default function reducer(state = initialState, action = {}) { + const { type, payload } = action; + + switch (type) { + case SET_USER_ID: + return { ...state, userId: payload }; + case SET_CURRENT_LISTINGS_CATEGORY: + return { ...state, listingCategory: payload }; + case INITIALIZE_UPPY: + return { ...state, uppy: payload.uppy, listings: payload.files }; + case ADD_FILE: + return { + ...state, + listings: [...state.listings, payload], + selectedRowsKeys: _.uniq([...state.selectedRowsKeys, payload.id]), + }; + case REMOVE_FILE: + return { ...state, listings: state.listings.filter(file => file.id !== payload.id) }; + case RESET_FILES: + return { ...state, listings: [] }; + case PREVIEW_GENERATED: { + const { id, preview } = payload; + return { + ...state, + listings: state.listings.map(listing => + listing.id === id + ? { + ...listing, + preview, + } + : listing + ), + }; + } + case FETCH_LISTING_OPTIONS: { + const { categories, usages, releases } = payload; + return { + ...state, + listingFieldsOptions: { + categories, + usages, + releases, + }, + }; + } + case UPDATE_LISTING: { + const { id, ...values } = payload; + return { + ...state, + listings: state.listings.map(file => (file.id === id ? { ...file, ...values } : file)), + }; + } + case SET_INVALID_LISTINGS: + return { ...state, invalidListings: payload }; + + case SET_AI_TERMS_ACCEPTED: + return { ...state, aiTermsStatus: AI_TERMS_STATUS_ACCEPTED }; + case SET_AI_TERMS_REQUIRED: + return { ...state, aiTermsStatus: AI_TERMS_STATUS_REQUIRED }; + case SET_AI_TERMS_NOT_REQUIRED: + return { ...state, aiTermsStatus: AI_TERMS_STATUS_NOT_REQUIRED }; + + case SET_SELECTED_ROWS: + return { ...state, selectedRowsKeys: payload }; + + case CREATE_LISTINGS_REQUEST: + return { ...state, createListingsInProgress: true, createListingsError: null }; + case CREATE_LISTINGS_ERROR: + return { + ...state, + createListingsSuccess: false, + createListingsInProgress: false, + createListingsError: payload, + }; + case CREATE_LISTINGS_ABORTED: + return { + ...state, + createListingsSuccess: null, + createListingsInProgress: false, + invalidListings: [], + }; + case CREATE_LISTINGS_SUCCESS: + return { + ...state, + createListingsSuccess: true, + createListingsInProgress: false, + createListingsError: null, + }; + case ADD_FAILED_LISTING: + return { ...state, failedListings: [...state.failedListings, payload] }; + case ADD_SUCCESSFUL_LISTING: + return { ...state, successfulListings: [...state.successfulListings, payload] }; + case RESET_STATE: + return initialState; + default: + return state; + } +} + +// ============== Selector =============== // +export const getUppyInstance = state => state.BatchEditListingPage.uppy; +export const getListingCategory = state => state.BatchEditListingPage.listingCategory; +export const getListings = state => state.BatchEditListingPage.listings; +export const getSingleListing = (state, id) => + state.BatchEditListingPage.listings.find(l => l.id === id); +export const getInvalidListings = state => state.BatchEditListingPage.invalidListings; +export const getListingFieldsOptions = state => state.BatchEditListingPage.listingFieldsOptions; +export const getSelectedRowsKeys = state => state.BatchEditListingPage.selectedRowsKeys; + +export const getListingCreationInProgress = state => + state.BatchEditListingPage.createListingsInProgress; +export const getAiTermsRequired = state => + state.BatchEditListingPage.aiTermsStatus === AI_TERMS_STATUS_REQUIRED; +export const getAiTermsAccepted = state => + state.BatchEditListingPage.aiTermsStatus === AI_TERMS_STATUS_ACCEPTED; + +export const getCreateListingsSuccess = state => state.BatchEditListingPage.createListingsSuccess; +export const getCreateListingsError = state => state.BatchEditListingPage.createListingsError; +export const getFailedListings = state => state.BatchEditListingPage.failedListings; +export const getPublishingData = state => { + const { failedListings, successfulListings, selectedRowsKeys } = state.BatchEditListingPage; + return { + failedListings, + successfulListings, + selectedRowsKeys, + }; +}; + +/** + * Handles the completion of a Transloadit result. + * + * @param dispatch + * @param {function} getState - Function to get the current state. + * @param {object} sdk - Instance of Sharetribe's SDK. + * @returns {function} - A function to handle the Transloadit result. + */ +function handleTransloaditResultComplete(dispatch, getState, sdk) { + return async (stepName, result, assembly) => { + const { localId, ssl_url } = result; + const uppyInstance = getUppyInstance(getState()); + const listing = getSingleListing(getState(), localId); + + try { + const price = Number(listing.price) * 100; + const category = getListingCategory(getState()); + + const listingData = { + title: listing.title, + description: listing.description, + publicData: { + listingType: 'product-listing', + categoryLevel1: category, + imageryCategory: listing.category, + usage: listing.usage, + releases: listing.releases, + keywords: listing.keywords, + imageSize: listing.dimensions, + fileType: '', + aiTerms: listing.isAi ? 'yes' : 'no', + originalFileName: listing.name, + }, + privateData: { + transloaditSslUrl: ssl_url, + }, + price: new Money(price, 'USD'), + }; + + await sdk.ownListings.create(listingData, { + expand: true, + }); + + dispatch({ type: ADD_SUCCESSFUL_LISTING, payload: listing }); + } catch (error) { + dispatch({ type: ADD_FAILED_LISTING, payload: listing }); + console.error('Error during image download or upload:', error); + } finally { + const { successfulListings, failedListings, selectedRowsKeys } = getPublishingData( + getState() + ); + const totalListingsProcessed = successfulListings.length + failedListings.length; + + if (totalListingsProcessed === selectedRowsKeys.length) { + const actionType = + failedListings.length > 0 ? CREATE_LISTINGS_ERROR : CREATE_LISTINGS_SUCCESS; + dispatch({ type: actionType }); + } + } + }; +} + +function updateAiTermsStatus(getState, dispatch) { + if (getAiTermsAccepted(getState())) { + return; + } + const listings = getListings(getState()); + const hasAi = listings.some(listing => listing.isAi); + dispatch({ type: hasAi ? SET_AI_TERMS_REQUIRED : SET_AI_TERMS_NOT_REQUIRED }); +} + +// ================ Thunk ================ // +export function initializeUppy(meta) { + return (dispatch, getState, sdk) => { + const store = getStore(); + const uppyInstance = createUppyInstance(store, meta, files => { + // Use the onBeforeUpload event to filter out files that are not selected + const selectedFilesIds = getSelectedRowsKeys(getState()); + + return selectedFilesIds.reduce((acc, key) => { + if (key in files) { + acc[key] = files[key]; + } + return acc; + }, {}); + }); + + dispatch({ + type: INITIALIZE_UPPY, + payload: { uppy: uppyInstance, files: uppyInstance.getFiles().map(uppyFileToListing) }, + }); + + uppyInstance.on('file-removed', file => { + dispatch({ type: REMOVE_FILE, payload: file }); + updateAiTermsStatus(getState, dispatch); + }); + + uppyInstance.on('file-added', file => { + const { id } = file; + const uppy = getUppyInstance(getState()); + + getFileMetadata(file, metadata => { + // set the metadata using Uppy interface and then retrieve it again with the updated info + uppy.setFileMeta(id, metadata); + + const newFile = uppy.getFile(id); + const listing = uppyFileToListing(newFile); + dispatch({ type: ADD_FILE, payload: listing }); + updateAiTermsStatus(getState, dispatch); + }); + }); + + uppyInstance.on('cancel-all', () => { + dispatch({ type: RESET_FILES }); + }); + + uppyInstance.on('thumbnail:generated', (file, preview) => { + const { id } = file; + const listing = getSingleListing(getState(), id); + if (!listing.preview) { + dispatch({ type: PREVIEW_GENERATED, payload: { id, preview } }); + } + }); + + uppyInstance.on('transloadit:result', handleTransloaditResultComplete(dispatch, getState, sdk)); + uppyInstance.on('error', error => { + console.log(error); + if (error.assembly) { + console.log(`Assembly ID ${error.assembly.assembly_id} failed!`); + console.log(error.assembly); + } + }); + }; +} + +export const requestUpdateListing = payload => (dispatch, getState, sdk) => { + dispatch({ type: UPDATE_LISTING, payload }); +}; + +export function requestSaveBatchListings() { + return (dispatch, getState, sdk) => { + dispatch({ type: CREATE_LISTINGS_REQUEST }); + + const selectedFilesIds = getSelectedRowsKeys(getState()); + const listings = getListings(getState()).filter(listing => + selectedFilesIds.includes(listing.id) + ); + + // 1. Validate required fields for all listings + const invalidListings = listings + .map(validateListingProperties) + .filter(result => result !== null); + + if (invalidListings.length > 0) { + // Dispatch action to store invalid file names in state and trigger modal + dispatch({ type: SET_INVALID_LISTINGS, payload: invalidListings.map(f => f.listing.name) }); + return; // Abort saving if there are invalid listings + } + + // 2. Check if any AI content is listed and if terms are accepted + const aiListings = listings.filter(listing => listing.isAi); + const aiTermsAccepted = getAiTermsAccepted(getState()); + if (aiListings.length > 0 && !aiTermsAccepted) { + // Dispatch action to trigger modal for AI terms + // Here you would display a different modal if AI listings are present + dispatch({ type: SET_AI_TERMS_REQUIRED }); + return; // Abort saving until terms are accepted + } + + // 3. Proceed with saving the listings if all validations pass + const uppy = getUppyInstance(getState()); + + uppy + .upload() + .then(result => { + const failedListings = getFailedListings(getState()); + if (failedListings.length > 0) { + dispatch({ type: CREATE_LISTINGS_ERROR }); + } else { + //dispatch({ type: CREATE_LISTINGS_SUCCESS }); + } + + console.info('Successful uploads:', result.successful); + }) + .catch(error => { + console.error(error); + }); + }; +} + +export const loadData = (params, search, config) => (dispatch, getState, sdk) => { + const { category } = params; + dispatch({ type: SET_CURRENT_LISTINGS_CATEGORY, payload: category }); + + const imageryCategoryOptions = getListingFieldOptions(config, 'imageryCategory'); + const usageOptions = getListingFieldOptions(config, 'usage'); + const releaseOptions = getListingFieldOptions(config, 'releases'); + dispatch({ + type: FETCH_LISTING_OPTIONS, + payload: { + categories: imageryCategoryOptions, + usages: usageOptions, + releases: releaseOptions, + }, + }); + + const fetchCurrentUserOptions = { + updateNotifications: false, + }; + return dispatch(fetchCurrentUser(fetchCurrentUserOptions)) + .then(response => { + dispatch({ type: SET_USER_ID, payload: response.id }); + return response; + }) + .catch(e => { + throw e; + }); +}; diff --git a/src/containers/BatchEditListingPage/BatchEditListingPage.js b/src/containers/BatchEditListingPage/BatchEditListingPage.js new file mode 100644 index 000000000..72f9e3e5e --- /dev/null +++ b/src/containers/BatchEditListingPage/BatchEditListingPage.js @@ -0,0 +1,100 @@ +import React, { useEffect } from 'react'; +import { compose } from 'redux'; +import { withRouter } from 'react-router-dom'; +import { connect } from 'react-redux'; +import { injectIntl } from '../../util/reactIntl'; +import { + NO_ACCESS_PAGE_POST_LISTINGS, + NO_ACCESS_PAGE_USER_PENDING_APPROVAL, +} from '../../util/urlHelpers'; +import { hasPermissionToPostListings, isUserAuthorized } from '../../util/userHelpers'; +import { NamedRedirect, Page } from '../../components'; +import TopbarContainer from '../../containers/TopbarContainer/TopbarContainer'; + +import css from './BatchEditListingPage.module.css'; +import { initializeUppy, requestSaveBatchListings } from './BatchEditListingPage.duck'; +import BatchEditListingWizard from './BatchEditListingWizard/BatchEditListingWizard'; + +export const BatchEditListingPageComponent = props => { + const { currentUser, history, intl, params, page, onInitializeUppy, onSaveBatchListing } = props; + const hasPostingRights = hasPermissionToPostListings(currentUser); + const shouldRedirectNoPostingRights = !!currentUser?.id && !hasPostingRights; + const { uppy, listingFieldsOptions } = page; + + useEffect(() => { + if (!uppy) { + onInitializeUppy({ userId: currentUser.id?.uuid }); + } + }, []); + + if (!isUserAuthorized(currentUser)) { + return ( + + ); + } else if (shouldRedirectNoPostingRights) { + return ( + + ); + } + + return ( + + + {uppy && ( + + )} + + ); +}; + +const mapStateToProps = state => { + const page = state.BatchEditListingPage; + + return { + currentUser: state.user.currentUser, + page, + }; +}; + +const mapDispatchToProps = dispatch => ({ + onInitializeUppy: uppyInstance => dispatch(initializeUppy(uppyInstance)), + onSaveBatchListing: () => { + dispatch(requestSaveBatchListings()); + }, +}); + +// Note: it is important that the withRouter HOC is **outside** the +// connect HOC, otherwise React Router won't rerender any Route +// components since connect implements a shouldComponentUpdate +// lifecycle hook. +// +// See: https://github.com/ReactTraining/react-router/issues/4671 +const BatchEditListingPage = compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps), + injectIntl +)(BatchEditListingPageComponent); + +export default BatchEditListingPage; diff --git a/src/containers/BatchEditListingPage/BatchEditListingPage.module.css b/src/containers/BatchEditListingPage/BatchEditListingPage.module.css new file mode 100644 index 000000000..cd530e346 --- /dev/null +++ b/src/containers/BatchEditListingPage/BatchEditListingPage.module.css @@ -0,0 +1,36 @@ +@import '../../styles/customMediaQueries.css'; + +.mobileTopbar { + /* Size */ + width: 100%; + height: var(--topbarHeight); + + /* Layout for child components */ + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + /* fill */ + background-color: var(--colorWhite); + + /* shadows */ + box-shadow: none; + + @media (--viewportLarge) { + display: none; + } +} + +.desktopTopbar, +.mobileTopbar { + box-shadow: none; + + @media (--viewportLarge) { + box-shadow: var(--boxShadowLight); + } +} + +.wizard { + flex-grow: 1; +} diff --git a/src/containers/BatchEditListingPage/BatchEditListingWizard/BatchEditListingProductDetails/EditListingBatchProductDetails.js b/src/containers/BatchEditListingPage/BatchEditListingWizard/BatchEditListingProductDetails/EditListingBatchProductDetails.js new file mode 100644 index 000000000..f0d5d01a7 --- /dev/null +++ b/src/containers/BatchEditListingPage/BatchEditListingWizard/BatchEditListingProductDetails/EditListingBatchProductDetails.js @@ -0,0 +1,249 @@ +import React, { useEffect, useState } from 'react'; +import css from './EditListingBatchProductDetails.module.css'; +import { Button, H3 } from '../../../../components'; +import { FormattedMessage } from '../../../../util/reactIntl'; +import { Checkbox, Flex, List, Modal, Progress, Space, Typography } from 'antd'; +import { + CREATE_LISTINGS_ABORTED, + getAiTermsRequired, + getInvalidListings, + getListingCreationInProgress, + getListingFieldsOptions, + getListings, + getPublishingData, + getSelectedRowsKeys, + requestSaveBatchListings, + requestUpdateListing, + SET_AI_TERMS_ACCEPTED, + SET_SELECTED_ROWS, +} from '../../BatchEditListingPage.duck'; +import { useDispatch, useSelector } from 'react-redux'; +import { EditableListingsTable } from './EditableListingsTable'; +import useStickyHeader from '../useStickyHeader'; +import { + ExclamationCircleOutlined, + FileExclamationOutlined, + WarningOutlined, +} from '@ant-design/icons'; + +const { Text, Paragraph } = Typography; + +function ListingValidationModalContent({ invalidListings }) { + return ( +
+ + + + + ( + + + {item} + + + )} + /> + + + + +
+ ); +} + +function AiTermsModalContent({ onTermsCheckboxChange }) { + return ( +
+ + + + + + + + +
+ ); +} + +export const EditListingBatchProductDetails = () => { + const dispatch = useDispatch(); + + const listings = useSelector(getListings); + const listingFieldsOptions = useSelector(getListingFieldsOptions); + const listingsCreationInProgress = useSelector(getListingCreationInProgress); + const invalidListings = useSelector(getInvalidListings); + const aiTermsRequired = useSelector(getAiTermsRequired); + const selectedRowKeys = useSelector(getSelectedRowsKeys); + const { failedListings, successfulListings, selectedRowsKeys } = useSelector(getPublishingData); + + const [dataSource, setDataSource] = useState(listings); + const [termsAcceptedCheckbox, setTermsAcceptedCheckbox] = useState(false); // Use state to track checkbox value + const [showValidationModal, setShowValidationModal] = useState(false); + const [showAiTermsModal, setShowAiTermsModal] = useState(false); + const [showProgressModal, setShowProgressModal] = useState(false); + + const onTermsCheckboxChange = e => { + setTermsAcceptedCheckbox(e.target.checked); + }; + + const onSelectChange = newSelectedRowKeys => { + dispatch({ type: SET_SELECTED_ROWS, payload: newSelectedRowKeys }); + }; + + const onSubmit = () => { + dispatch(requestSaveBatchListings()); + }; + + const handleUpdateListing = updatedData => { + dispatch(requestUpdateListing(updatedData)); + }; + + const handleCancelValidationModal = () => { + setShowValidationModal(false); + dispatch({ type: CREATE_LISTINGS_ABORTED }); + }; + + const handleCancelAiTermsModal = () => { + setShowAiTermsModal(false); + dispatch({ type: CREATE_LISTINGS_ABORTED }); + }; + + const handleOkAiTermsModal = () => { + if (termsAcceptedCheckbox) { + dispatch({ type: SET_AI_TERMS_ACCEPTED }); + dispatch({ type: CREATE_LISTINGS_ABORTED }); + onSubmit(); + } else { + dispatch({ type: CREATE_LISTINGS_ABORTED }); + } + + setShowAiTermsModal(false); + }; + + useEffect(() => { + setDataSource(listings); + }, [listings]); + + useEffect(() => { + if (invalidListings.length > 0) { + setShowValidationModal(listingsCreationInProgress); + return; + } + + if (aiTermsRequired && listings.some(listing => listing.isAi)) { + setShowAiTermsModal(listingsCreationInProgress); + return; + } + + setShowProgressModal(listingsCreationInProgress); + }, [ + invalidListings, + aiTermsRequired, + listingsCreationInProgress, + failedListings, + successfulListings, + selectedRowsKeys, + ]); + + useStickyHeader(css); + + return ( +
+ + +

+ +

+ + + + + + + + + +
+ + + +
+ +
+ +
+ + + + + + + + } + open={showValidationModal} + onOk={handleCancelValidationModal} + onCancel={handleCancelValidationModal} + cancelButtonProps={{ hidden: true }} + width={800} + > + + + + + + + + + + } + open={showAiTermsModal} + onOk={handleOkAiTermsModal} + onCancel={handleCancelAiTermsModal} + okButtonProps={{ disabled: !termsAcceptedCheckbox }} + width={800} + > + + + + +

+ +

+ + + + {failedListings?.length > 0 && {failedListings.length} files failed} +
+
+ ); +}; diff --git a/src/containers/BatchEditListingPage/BatchEditListingWizard/BatchEditListingProductDetails/EditListingBatchProductDetails.module.css b/src/containers/BatchEditListingPage/BatchEditListingWizard/BatchEditListingProductDetails/EditListingBatchProductDetails.module.css new file mode 100644 index 000000000..9830fc7e7 --- /dev/null +++ b/src/containers/BatchEditListingPage/BatchEditListingWizard/BatchEditListingProductDetails/EditListingBatchProductDetails.module.css @@ -0,0 +1,91 @@ +@import '../../../../styles/customMediaQueries.css'; + +.root { + width: 100%; + height: auto; + display: flex; + flex: 1; + flex-direction: column; + padding: 24px 36px; + background-color: var(--colorWhite); + + @media (--viewportLarge) { + width: 80%; + } +} + +.submitButton { + margin-top: auto; + flex-shrink: 0; + text-wrap: nowrap; + padding: 12px; + + @media (--viewportLarge) { + display: inline-block; + } +} + +.editableRow:hover .editableCellValueWrap { + padding: 4px 11px; + border: 1px solid #d9d9d9; + border-radius: 2px; +} + +.editableCellValueWrap { + padding: 5px 12px; + cursor: pointer; +} + +.stickyHeader { + position: sticky; + top: 0; + background-color: var(--colorWhite); + z-index: 100; + justify-content: space-between; +} + +.stickyTitle { + margin: 0; +} + +.stickyHeader .subTitle { + transition: opacity 0.3s ease, max-height 0.3s ease; + opacity: 1; + overflow: hidden; +} + +.subTitle.hidden { + opacity: 0; + max-height: 0; /* Collapse subtitle */ +} + +.buttonWrapper { + justify-content: center; + align-self: center; + padding: 0 24px; +} + +.stickyHeader.scrolled .submitButton { + transform: scale(0.8); /* Shrink button */ +} + +.displayCell { + margin-bottom: 24px; +} + +.formItem input:focus, +.formItem textarea:focus, +.formItem select:focus { + border: none; + box-shadow: none; +} + +.modalContent { + padding: var(--modalPadding); + margin: 1rem; + padding-bottom: 24px; +} + +.modalContent .modalBottom { + margin-top: 48px; +} diff --git a/src/containers/BatchEditListingPage/BatchEditListingWizard/BatchEditListingProductDetails/EditableCellComponents.js b/src/containers/BatchEditListingPage/BatchEditListingWizard/BatchEditListingProductDetails/EditableCellComponents.js new file mode 100644 index 000000000..9045a191d --- /dev/null +++ b/src/containers/BatchEditListingPage/BatchEditListingWizard/BatchEditListingProductDetails/EditableCellComponents.js @@ -0,0 +1,132 @@ +import React, { useContext } from 'react'; +import { Form, Input, InputNumber, Select, Switch } from 'antd'; +import css from './EditListingBatchProductDetails.module.css'; +import { MAX_KEYWORDS } from '../../BatchEditListingPage.duck'; + +const { TextArea } = Input; + +const EditableContext = React.createContext(null); +const EditableCell = props => { + const { + title, + editable, + children, + dataIndex, + record, + handleSave, + editControlType, + options, + cellClassName, + onBeforeSave = null, + placeholder = '', + ...restProps + } = props; + const form = useContext(EditableContext); + const value = record ? record[dataIndex] : ''; + + const save = async () => { + try { + let values = await form.getFieldsValue(); + if (onBeforeSave) { + values = onBeforeSave(values); + form.setFieldsValue(values); + } + + handleSave({ ...record, ...values }); + } catch (errInfo) { + console.log('Save failed:', errInfo); + } + }; + + return ( + + {editable ? ( +
+ + { + { + text: , + textarea: , + selectMultiple: ( + + ), + tags: ( +